Approval workflow · Open source
Change Requests
Holds edits to Filament records as change requests that a reviewer checks field by field, then approves, rejects or rolls back.
What it solves
In a Filament admin, whoever can open the edit form can change a price, a stock level or a status, and the change is live the moment they press Save.
Many teams need a second person to check those edits first, but a hand-built approval step means a pending-changes table, a diff screen, review permissions and Filament pages that stop saying "Saved" for a change that has not been saved.
Add cjmellor/approval's MustBeApproved trait to a model and the plugin's page traits to its Edit and Create pages. A held save tells the editor "Changes submitted for approval" and puts the form back to the stored values. Reviewers get a Change requests resource with a Before and After table for each request, approve and reject on each row, in bulk, on the request page or from a dashboard widget, and roll back on the request page. By default nobody can approve, reject or roll back their own request. Built on cjmellor/approval by Chris Mellor. An independent plugin, not made or endorsed by Filament.
- Edit and Create page traits: a held edit says "Changes submitted for approval" and refills the form with the stored values, a held create says "Submitted for approval" and returns to the list
- Change requests resource that opens on pending requests, with status, type-of-record and per-record filters and a pending count in the navigation
- Field-by-field Before and After table; password, remember_token, api_token and the two-factor columns are masked, and you can mask more
- Approve and reject per row, in bulk, on the request page or from a dashboard widget; roll back an approved request on its request page
- Self-review blocked by default, then your callback, a reviewChangeRequest gate or the model's update policy decides who reviews
- Approving a held create links the request to the record it creates
- Pending-changes count column, "Has pending changes" filter and a "Pending changes (n)" header button for your own resources
Install
Requires PHP 8.3+, Filament 4 and 5, and Laravel 12.4+ and 13.
composer require amjadiqbal/filament-change-requests
Usage
use AmjadIqbal\FilamentChangeRequests\ChangeRequestsPlugin;
use AmjadIqbal\FilamentChangeRequests\Pages\Concerns\SubmitsChangeRequests;
use Cjmellor\Approval\Concerns\MustBeApproved;
class Product extends Model
{
use MustBeApproved;
// Optional: only these attributes need approval
protected array $approvalAttributes = ['price', 'stock'];
}
class EditProduct extends EditRecord
{
use SubmitsChangeRequests;
}
// AdminPanelProvider
$panel->plugin(ChangeRequestsPlugin::make());
Setup
cjmellor/approval is installed with the plugin and stores every held change in its approvals table, so its migration has to run first; follow its README for that step. Then publish the plugin's stylesheet and register the plugin on the panel, which adds the Change requests resource. ->resource(false) leaves the resource out, and ->resource(MyResource::class) swaps in your own subclass.
- 1 composer require amjadiqbal/filament-change-requests
- 2 Run cjmellor/approval's migration (see its README), then php artisan migrate
- 3 php artisan filament:assets
- 4 Add ->plugin(ChangeRequestsPlugin::make()) to the panel provider.
- 5 Add use MustBeApproved; to each model that needs approval, use SubmitsChangeRequests; to its EditRecord page and use SubmitsCreationRequests; to its CreateRecord page.
How edits are held
Holding the change is done by cjmellor/approval's MustBeApproved trait on the model; set $approvalAttributes to hold only some attributes, and the rest save straight away. The page traits only change what the editor sees. After a save, SubmitsChangeRequests looks for a change request created by that save; if it finds one, it reloads the record, refills the form with the stored values, shows "Changes submitted for approval" instead of "Saved" and stays on the page. Without the trait, Filament reports a held edit as saved.
- ViewChangeRequestsAction in the Edit or View page header shows "Pending changes (n)" when the record has pending requests and links to the change requests list filtered to that record.
- PendingChangesColumn adds a sortable pending count to the model's table (counted in the table query), and PendingChangesFilter adds a "Has pending changes" toggle. Both use the model's approvals relationship from MustBeApproved.
- Only the model's own attributes go through the request. The plugin does nothing for relationship fields, so check how relationship and repeater fields in your form behave before relying on the hold for them.
- Deletes are not handled: the plugin has no delete hook, and a request whose record no longer exists is listed as "Model #id (deleted)".
New records
cjmellor/approval also holds a new record, so nothing is inserted until it is approved. SubmitsCreationRequests detects the held create, shows "Submitted for approval", redirects to the resource list and stops the page there, so Filament does not report "Created" or redirect to a record that does not exist. Because nothing after the hold runs, relationship fields on the create form are not saved with it. When a held create is approved, the plugin catches the newly created model and writes its key back to the change request, so the request links to the new record afterwards.
The review page
The Change requests list opens filtered to pending requests and shows the record, the changed fields as badges, who asked, the status and when; a Reviewed by column can be toggled on. Each request's page has a Summary card (record, type Edit or New record, status, requested by, requested and reviewed by, plus the roll-back time when there is one) and a Changes table with Field, Before and After columns. The list, the request pages and the widget have no create, edit or delete actions.
- One row per attribute in the request. Field names are headlined (unit_price becomes Unit Price) unless you set ->attributeLabels(['sku' => 'SKU']); form field labels are not used.
- Values are shown as text: empty as a dash, booleans as Yes or No, enums by their value, dates as Y-m-d H:i:s, and arrays or JSON columns as pretty-printed JSON. A file upload field shows the stored path, not a preview.
- Masked by default: password, remember_token, api_token, two_factor_secret and two_factor_recovery_codes. ->maskedAttributes(['iban']) adds more; any other secret column is shown unless you list it.
- Statuses are Pending, Approved, Rejected and Rolled back, plus any custom states from config('approval.states'). Approve and reject only appear on requests that are pending with no custom state.
- The PendingChangeRequestsWidget lists pending requests five per page with Approve, Reject and Review; add it to the panel's widgets yourself.
Approve, reject, roll back
Approve hands the request to cjmellor/approval, which writes the new values to the record. Reject marks the request rejected and leaves the record as it is. Bulk approve and reject skip any request that is not pending or that you may not review, and report how many were skipped. Roll back is only on the request page, and only for an approved request that has not been rolled back and whose record still exists; it calls cjmellor/approval's rollback, which writes the original values stored on the request (the Before column) back to the record. It is not a snapshot of the whole record, so an edit made to those fields after the approval is replaced as well. A request can be rolled back once, and its page keeps showing the change as it was requested.
Who can review
One check covers approve, reject and roll back, and it runs in this order. Out of the box, with no callback, no gate and no policy on the model, any signed-in panel user except the person who made the request can approve, reject or roll it back. The plugin does not limit who can open the Change requests list or read the diffs; use a policy for cjmellor's Approval model or your own resource subclass for that.
- Self-review: when the request has a recorded creator and it is the current user, the answer is no. ->preventSelfReview(false) turns this off. A request with no recorded creator is not blocked.
- Your callback: ->authorizeUsing(fn ($approval, $user) => ...) decides alone when set; the gate and policy are not checked.
- Gate: if the app defines a reviewChangeRequest gate, it decides alone.
- Policy: if the model has a policy, a user who may update the record can review edits to it. For a held create, or a request whose record is gone, the create ability is checked instead.
- No policy: anyone signed in to the panel may review.
- With a typical policy where every editor may update, editors can approve each other's changes. Use the callback or the gate to limit approval to specific people or roles.
ChangeRequestsPlugin::make()
->authorizeUsing(fn (Approval $approval, User $user): bool => $user->is_admin)
->navigationGroup('Catalog')
->attributeLabels(['sku' => 'SKU'])
->maskedAttributes(['iban']);
// or, in a service provider
Gate::define('reviewChangeRequest', fn (User $user, Approval $approval): bool => $user->hasRole('editor-in-chief'));
Credits
Change Requests is built on cjmellor/approval by Chris Mellor (MIT), which stores and applies the held changes. This plugin adds the Filament side: the pages, the diff, the actions and the review rules. It is not affiliated with or endorsed by Chris Mellor or the cjmellor/approval project.
Hire me
Need this built into your app, or something custom?
I wrote Change Requests. I can wire it into your Filament panel, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.
Support
Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.