
A tested draft-editing workflow shows where Filament and a custom endpoint share business rules, and how an alternate save action can bypass conflict checks.
Compare the write paths, not the screenshots
The prototype for this comparison could reject a stale edit on its Filament edit page and still overwrite it through a table action. Both paths belonged to the same resource. The table action retained a separate writer, so customizing the edit page had not protected every way an authorized administrator could save.
The worked task is deliberately small: an administrator changes the title of a draft post. Published posts cannot be edited here, deletion is forbidden, and a stale form must not replace another editor’s change. The fixture implements a Filament resource and a custom Laravel PATCH endpoint using the same application action. It does not implement a complete competing custom frontend.
The executed stack is PHP 8.5.6, Laravel 12.69.3, Filament 3.3.57 and Livewire 3.8.10, with PHPUnit 12.5.39 and Testbench 10.12.0. The dependency lock is in the source bundle. Filament’s 3.x documentation labels that major historical. This example supports maintainers of an existing panel, not a recommendation to start a new application on that major.
Keep permission and deletion rules explicit
The fixture registers this policy for Post and casts User.is_admin to boolean. It permits administrators to list and view posts, but update requires a draft. Both single and bulk deletion are denied. Filament documents deleteAny as the bulk-deletion policy method, so defining only delete would leave that decision unspecified.
The custom endpoint authorizes before validation and calls the shared save action. In the tests a guest receives 401, a non-admin receives 403, and an administrator attempting to change a published post receives 403. Direct panel URLs redirect a guest to login and reject a non-admin. Each denied write leaves the stored title unchanged.
<?php
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
class PostPolicy
{
public function viewAny(User $user): bool
{
return $user->is_admin === true;
}
public function view(User $user, Post $post): bool
{
return $user->is_admin === true;
}
public function update(User $user, Post $post): bool
{
return $user->is_admin === true && $post->status === "draft";
}
public function delete(User $user, Post $post): bool
{
return false;
}
public function deleteAny(User $user): bool
{
return false;
}
}
Make a stale write fail at the database update
A policy checks the model it receives. That model can be older than the row being written. SaveDraft first authorizes the user and validates the title, then updates only the row whose status is still draft and whose version matches the version the editor opened. The same statement increments version. If no row matches, the action returns a 409 conflict instead of silently overwriting.
The Filament edit page captures version when filling the form, protects that property with Livewire’s Locked attribute, and passes it through handleRecordUpdate to SaveDraft. After a successful save it advances the captured version, allowing another save without reopening the page. The custom endpoint accepts an explicit expected version. The bundle includes both integrations, schema and registered policy.
This conditional update assumes other writers obey the same version protocol. An import that changes title without advancing version defeats stale-edit detection. A query-level update also does not run per-model saving or updated observers. If the application needs those events or related writes, design that transaction and event behavior explicitly rather than copying this single-row action unchanged.
<?php
namespace App\Actions;
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
class SaveDraft
{
public function handle(User $user, Post $post, string $title, int $expectedVersion): Post
{
Gate::forUser($user)->authorize('update', $post);
$data = Validator::make(['title' => trim($title)], ['title' => ['required', 'string', 'max:200']])->validate();
$changed = Post::query()->whereKey($post->getKey())
->where('status', 'draft')
->where('version', $expectedVersion)
->update(['title' => $data['title'], 'version' => DB::raw('version + 1')]);
abort_unless($changed === 1, 409, 'Post changed; reload before saving.');
return $post->fresh();
}
}
A link can still have a separate save callback
The first table used the built-in EditAction, which had an edit-page URL but still carried its own update callback. The regression test mounted that action directly, changed the database to another editor’s title, then invoked the mounted action with a replacement title. The stored value became Overwrite instead of remaining Other editor. The caller was an authorized administrator: this was a missing concurrency rule in our prototype, not evidence of a general authentication bypass.
The corrected resource uses a plain table Action with an edit-page URL and no write callback. Its visibility follows canEdit. The edit page remains the place that calls SaveDraft. The test still invokes the table action directly and now confirms the other editor’s value survives. An application that needs modal editing should instead wire that modal’s save path to the same operation and test its captured version.
Filament’s locked EditRecord source checks authorization on mount, hydration and save. The conditional update addresses a different problem: state can change after an allowed decision, and another caller can hold a stale model. Do not describe this example as adding a save-time permission check that Filament lacked.
Reproduce the failures before trusting the result
Download and extract the bundle, then run python3 frontend/scripts/verify-filament.py from its root with Docker available. Building retrieves locked dependencies; test execution has no network or host credentials. The report records exact source hashes and versions. Thirteen boundary tests pass, covering both entry points, validation, repeated saves, stale state, client version tampering and direct single/bulk deletion requests.
The runner also executes three deliberately broken variants. Removing the status condition permits a stale model to write after publication. Removing the version condition changes an expected 409 to 200. Restoring the table EditAction overwrites the other editor’s title. Each variant must produce its expected assertion failure. An unrelated exception or skipped test is not accepted as evidence.
These are in-process HTTP and Livewire component tests with SQLite. They simulate ordered conflicting writes, not simultaneous production database traffic. They do not verify browser login, CSRF, tenant isolation, keyboard interaction or operator recovery from a conflict. The prototype reports a conflict but has no merge/retry interface. Those remain acceptance work before using it as an admin product.
Choose based on the remaining interface work
I recommend prototyping a Filament resource first for a record-oriented internal workflow and list every extra save path it creates. This example needed a custom save hook, captured version state and a navigation-only table action. The policy and database operation were needed by both approaches, so they are not evidence that a custom frontend costs less.
I recommend evaluating a custom frontend when the required interaction itself is the difficult part: for example, operators arranging a dispatch board or keeping unsaved edits across many records. Compare the actual exception and recovery workflow, including validation feedback, conflict recovery and keyboard use. This fixture provides no development-time, performance or usability measurements that could settle that choice. It supplies a reusable write boundary and tests that a fuller prototype must preserve.
Reproduce the examples
Locked Laravel/Filament fixture, both write paths, thirteen boundary tests and three negative controls.
Download the source and tests (.zip)
