bytelaunch/readonly-on-update
A Laravel package that makes selected Eloquent model attributes read-only after creation. Mark fields as immutable to prevent accidental changes during updates, saves, or mass assignment while still allowing them to be set when the record is first created.
Install
composer require bytelaunch/readonly-on-update
There is no config file or migration to publish in a package of this shape — it is a Nova field decorator. Before writing any code, open two files in the repo:
README.md — confirm the exact entry point (typically a field class ReadonlyOnUpdate or a Field::readonlyOnUpdate() macro).src/ and composer.json — check the Nova version constraint (laravel/nova: ^4.0 vs ^5.0) and whether it depends on laravel/nova directly or nova-kit/nova. A mismatch here is the single most common reason the tool silently does nothing.First use case
The canonical use is protecting identity/immutable columns on the edit form while keeping them editable on create: slug, sku, employee_number, external_id, parent_id, and anything referenced by downstream systems.
In a Nova resource:
use Bytelaunch\ReadonlyOnUpdate\ReadonlyOnUpdate;
public function fields(NovaRequest $request): array
{
return [
ReadonlyOnUpdate::make('SKU')
->rules('required', 'max:64'),
// or, if the package exposes a macro instead:
Text::make('SKU')->readonlyOnUpdate(),
];
}
Then navigate to /nova/resources/{resource}/new (editable) and /nova/resources/{resource}/{id}/edit (locked). If the field is editable on both, the package is not detecting the update request — see Gotchas.
Wrap the field, don’t subclass it. The package works by intercepting the field factory call and toggling readonly() based on $request->isMethod('PUT') / PATCH. Keep chaining Nova’s own fluent API (->rules(), ->help(), ->sortable()) after the wrapper so you don’t lose the underlying field type:
ReadonlyOnUpdate::make('Invoice Number')
->rules('required', 'alpha_dash', 'max:32')
->help('Cannot be changed after creation.')
->sortable(),
Apply it selectively by resource lifecycle. For resources where the record can be re-opened for edits after an import (e.g. a nightly sync rewrites the record), a plain "readonly on update" rule is wrong. Gate it:
ReadonlyOnUpdate::make('External ID')
->readonly(fn (NovaRequest $request) => $request->resourceId !== null
&& $request->findModelQuery()->first()?->source === 'manual'),
Use it for BelongsTo foreign keys deliberately. Locking owner_id on update is a very common requirement, but note that a readonly BelongsTo renders as text and hides the searchable select. Confirm that’s the UX you want, otherwise use ->canSee() + a policy instead.
Pair it with HasMany/Repeater children. If you have line items you want frozen after the parent is created, the request method is still PUT for the whole payload, so wrapping the child fields works — but only if Nova is posting the repeater in the same request. Verify with an actual save; Nova sometimes splits nested updates.
Guard the API layer too. Readonly is a UI hint, not a security control. Any field you lock should also be excluded from mass assignment at the model level ($fillable), or protected by an UpdateXRequest rule such as Rule::prohibitedIf($record->exists). Treat the package as UX polish, not authorization.
Testing. In a feature/Dusk test, assert both states by hitting the two routes:
$this->actingAs($user)
->get("/nova/resources/invoices/{$invoice->id}/edit")
->assertDontSee('name="sku"'); // or whatever the readonly render omits
If you can’t easily assert the DOM, drop a ->help(...) string on the field and assert its presence only in the update context.
Method detection is the whole trick. Nova’s update request is PUT to /nova-api/{resource}/{id} (occasionally PATCH from custom tooling, and POST from some Action implementations). If you call the resource through a custom controller or a Lens-driven bulk update, the package may not fire. Confirm with dd($request->method(), $request->path()) inside a temporary field callback.
readonly() ≠ excluded from the payload. In Nova, readonly fields are not submitted, so the underlying attribute is simply not updated. That is normally what you want — but if an UpdateResource observer or saving hook expects the value to be present, it will receive null. Check your model events.
Validation still runs. Keep required on the field. Because the value isn’t posted on update, a bare required rule on a readonly field is safe (Nova skips validation for absent readonly fields in most versions) — but if you see spurious "field is required" errors, switch to ->rules('sometimes', 'required', ...) or Rule::requiredIf(fn () => $request->isMethod('POST')).
Inline creation (BelongsTo “create new” modal) is a POST, so the field stays editable there — which is correct, but it surprises people who expect any nested form to be locked. Test the modal explicitly.
Policy conflicts. If a Field::canSee() or authorize() returns false, the wrapper’s readonly() never matters. Order of precedence in Nova is visibility → readonly → disabled. Debug by toggling each independently.
Nova 5 / nova-kit forks. The API surface of Field changed marginally between Nova 4 and 5 (notably around resolveUsing and fillUsing). If the wrapper swallows your ->fillUsing() callback, that’s the symptom — pin the package version that matches your Nova major, or fork and adjust the composer.json constraint.
Extension point. If the package is thin (likely, given its size), the fastest fix for edge cases is a local macro in a service provider:
Field::macro('readonlyOnUpdate', function () {
return $this->readonly(request()->isMethod('PUT') || request()->isMethod('PATCH'));
});
Two lines replicate the package and remove a dependency if it becomes unmaintained.
Debugging checklist when the field stays editable on edit: (1) is the package auto-discovered / is the provider registered if it ships one? (2) is the route actually hitting nova-api and not a custom endpoint? (3) is another field with the same attribute later in fields() overwriting yours? (4) is ->readonly(false) being chained after the wrapper by a trait or fieldsForUpdate() method?
How can I help you explore Laravel packages today?