laravel-enso/versioning
Laravel Enso Versioning provides transparent versioning for Eloquent models, automatically recording changes and letting you review or restore previous revisions. It integrates with the Enso ecosystem for permissions and UI-driven version management.
The package gives you optimistic concurrency control for Eloquent models: each record carries a version counter; an update carrying a stale version is rejected instead of silently overwriting another user's change.
composer require laravel-enso/versioning. The package is archived and targets older Laravel/Enso versions, so on Laravel 10+ you may need to pin it in composer.json or vendor the trait by hand. Register Enso\Versioning\VersioningServiceProvider if it is not auto-discovered.$table->unsignedInteger('version')->default(1) to the model's table. Check src/Traits/Versionable.php for the exact column name, default, and cast the trait expects before writing your own migration.use Versionable; on the model whose edits you care about — an invoice, a CMS page, a settings singleton. Don't blanket-apply it; it earns its keep only where two people plausibly edit the same row.<input type="hidden" name="version" value="{{ $invoice->version }}">). When the form posts, the trait compares the submitted version against the stored one. If they differ, you get a conflict instead of a lost update.app/Exceptions/Handler.php (or a FormRequest) and return HTTP 409 with the fresh record so the front end can show "this record changed while you were editing."Hidden field / Inertia props. The version must travel with the edit payload. With Blade, a hidden input; with Inertia, pass version in the page prop and send it back on submit. Never trust the client to compute it.
Conflict response shape. Return 409 with the current server-side record. The front end then has three options: reload and discard the user's edits, show a diff, or offer a merge. A bare 409 with no body forces a page refresh and loses work, which defeats the point.
API / If-Match mapping. For REST endpoints, map the version to an ETag or If-Match header. The check the trait performs is exactly precondition-failed semantics, so 428 (missing version) and 412/409 (stale version) are reasonable codes.
Guard writes in bulk operations. Only wrap models where every write path goes through an editable form. If a model is also updated by queue jobs, schedulers, or webhooks, those paths either need to opt out of the check or read-then-write with a retry.
Atomicity when it matters. The trait does check-then-write, not compare-and-swap. If concurrent writers are realistic, wrap the save in a transaction with lockForUpdate() and read the row before validating the version. Otherwise two requests can both pass the check and the second still wins.
Testing. Two-request tests are the honest way to verify it: fetch record A, update it, then attempt a second update with A's original version and assert 409. Mirror that in a feature test so a future Laravel upgrade that breaks the trait fails loudly.
Front-end UX. On 409, keep the user's typed values in the form and show a banner with the fresh server values alongside, so they can reconcile. Reloading on conflict is the correct default only if the form is trivial.
laravel-enso/core. Prefer the built-in updated_at/If-Unmodified-Since or a hand-rolled version column for new code; treat this package as a reference implementation.version isn't fillable (or is guarded), $model->fill($request->all()) silently drops it and the check compares against the default. Set it explicitly with forceFill or $model->version = $request->integer('version').Model::where(...)->update(['status' => 'paid']) fires no model events, so the trait never runs. Any code path that uses query-builder updates is unprotected — audit for these before assuming the model is safe.WHERE version = ? guard in the UPDATE gives you real protection.try/catch in controllers.updated_at and internal counters, clients start relying on them. Treat the version as a contract you version too.src/Traits/Versionable.php and any published migration; column name, nullability, and initial value all have to match or every update silently conflicts or silently passes.How can I help you explore Laravel packages today?