Skip to content

Eloquent model mechanics

Status: Complete. Last reviewed 2026-08-27.

An Eloquent model is an Active Record object: it combines a row-shaped attribute container, persistence operations, query entry points, and lifecycle hooks. A retrieved Order is therefore neither a plain database row nor a permanently synchronized domain entity. It is an in-memory snapshot with raw attributes, transformed values exposed through casts and accessors, an original snapshot for change tracking, and flags such as exists and wasRecentlyCreated.

That distinction explains several common surprises. Assigning $order->status changes memory but does not write until a persistence operation runs. Another transaction can change the row while this object remains stale. A cast can return an enum without changing the database column type. A model event can observe an attempted transition, but a query-level bulk update can bypass that event entirely.

Eloquent provides convenient persistence behavior, not aggregate consistency, authorization, or concurrency control. $fillable is not a list of fields an actor may edit. An observer is not a durable message bus. A global scope is not a complete tenant boundary. Those guarantees need explicit application and database design.

This page owns model attributes, casts, persistence state, scopes, events, observers, and mass operations. Relationship construction, loading, N+1 behavior, and serialization queries belong to Eloquent relationships and loading.

Querying through Eloquent creates model instances from database results. Hydration sets raw attributes, synchronizes the original snapshot, marks the instance as existing, and dispatches retrieved. It does not treat database columns as untrusted mass-assignment input. By contrast, constructing a model with an array or calling fill(), create(), or instance update() takes a mass-assignment path and filters keys through the model’s fillable/guarded configuration.

Direct assignment is different again:

$order->status = OrderStatus::Paid;
$order->save();

The assignment runs attribute mutation/casting rules but does not consult $fillable; save() persists the current model state. That is intentional—application code must be able to assign protected fields deliberately—but it means mass-assignment protection cannot replace mapping, authorization, or domain rules.

Prefer a narrow allow-list for arrays crossing a boundary. Laravel 13 documents model attributes such as #[Fillable([...])] and #[Guarded([...])]; the long-standing $fillable and $guarded properties remain familiar in Laravel 11–12 applications. If a model is unguarded, hand-craft every array sent to fill, create, or update. Never pass $request->all() merely because a model has a guard list: future changes to either side can silently expose authority-bearing fields.

Unfillable keys are silently discarded by default. Enabling Model::preventSilentlyDiscardingAttributes() outside production turns that ambiguity into an exception during development and tests. This catches spelling errors and forgotten allow-list entries, but is still a diagnostic setting, not an input policy.

Casts, accessors, mutators, and invariants

Section titled “Casts, accessors, mutators, and invariants”

An attribute starts as a raw database value. A cast defines how Eloquent converts between that stored representation and the value exposed by the model. Built-in casts cover booleans, dates, arrays/JSON, encrypted values, and other common representations; backed-enum and custom casts can expose stronger application types. Laravel 13’s documented form is a protected casts(): array method:

protected function casts(): array
{
return [
'status' => OrderStatus::class,
'placed_at' => 'immutable_datetime',
'metadata' => 'array',
];
}

An accessor/mutator declared with Attribute is useful when a field needs computed get/set behavior rather than a reusable cast type. A custom cast is preferable when the mapping is shared or represents a named storage concern. A value-object cast can combine several columns and Eloquent caches returned objects by default so mutations can be synchronized before save. That identity behavior matters: disabling object caching changes whether repeated reads return the same instance.

Casts do not change the database schema and do not prove that stored data is valid. An enum cast can fail when a legacy row contains an unknown backing value. A JSON-to-array cast does not validate keys. Encrypted casts need appropriately sized text columns and make exact-value querying impractical because ciphertext varies. Database constraints and explicit input/domain validation still own those guarantees.

Avoid cast names that collide with relationships or the primary key, and be careful with partial selects. Accessing an omitted attribute can look like a legitimate null unless strict missing-attribute access is enabled. Model::shouldBeStrict() combines missing-attribute, discarded-attribute, and lazy-loading checks; teams often enable it conditionally so development exposes ambiguous model behavior without turning a production-only data edge into an unplanned outage.

A model retains an original attribute snapshot alongside its current attributes. isDirty() asks whether current transformed-for-storage values differ from that snapshot before a save; getDirty() returns the pending changes. isClean() is its inverse. After persistence synchronizes state, wasChanged() describes what changed during the most recent save operation on that instance. Use these as observations, not as concurrency guarantees: they compare one PHP object’s snapshots, not the current row against another transaction.

For a new model, save() runs an insert path and normally sets timestamps. For an existing model, it runs an update only when dirty persisted attributes require one. A successful save synchronizes the original state. saveOrFail() wraps saving in a transaction and propagates failure; it does not supply a business-wide transaction around other writes unless they are included deliberately.

fresh() returns a new instance loaded from the database while leaving the original object untouched. refresh() reloads the existing instance in place and discards its unsaved attribute changes. Both can destroy an assumption that local state still represents the intended command, so use them deliberately after database-generated values or known external writes, not as a ritual fix for stale data.

Optimistic concurrency needs an explicit predicate, such as updating where both id and version match and then checking the affected-row count. Pessimistic concurrency needs a database transaction and lockForUpdate() around the read and decision. isDirty() alone prevents neither lost updates nor duplicate creation.

Model events, observers, and transaction timing

Section titled “Model events, observers, and transaction timing”

The instance persistence path emits model events. A normal insert passes through saving, creating, created, and saved; an update uses saving, updating, updated, and saved. Other events cover retrieval, deletion, restoration, replication, and related lifecycle points. Before-events can reject a save by returning false, but hidden vetoes are hard to diagnose; explicit application validation is usually clearer for an expected business refusal.

Use a model observer when several model lifecycle handlers form one persistence concern and must apply to every instance-based Eloquent write. Examples include deriving a normalized storage field or recording a small audit fact. Keep event handlers bounded and idempotent. Network calls, large recomputations, and implicit writes to many other models make save() slow, re-entrant, and difficult to reason about.

Transaction timing is critical. An ordinary created observer may run while the surrounding transaction is still open. External work dispatched there can execute before commit, read no row, observe old data, or survive a later rollback. In Laravel 13 an observer implementing ShouldHandleEventsAfterCommit is handled only after the transaction commits; rolled-back work is discarded. That improves visibility ordering but does not make an external side effect atomic with the database commit. For high-value integration events, use an outbox record written in the same transaction and deliver it with retries.

Events can be deliberately muted with methods such as saveQuietly() or withoutEvents(). Treat those as semantic choices requiring review, not performance incantations, because every invariant or side effect attached to an observer is skipped.

Model::where(...)->update([...]) is a set-based SQL update. It does not retrieve each model, run its casts/accessor setters as an instance assignment would, or dispatch saving, updating, updated, and saved for affected models. Query-level deletes similarly skip per-model delete events. This is why a bulk status migration can leave audit records, derived fields, search indexes, or notifications inconsistent even though the database rows changed correctly.

The choice is not “bulk updates are unsafe.” Set-based operations are often the correct, bounded way to change many rows. Before using one, inventory which model behavior you intend to bypass, encode required values directly in SQL-compatible form, protect invariants with constraints, and trigger any aggregate follow-up explicitly. If every row genuinely needs PHP behavior, iterate by a stable key with chunkById() rather than offset chunking, and make the operation restartable. Row-by-row saves cost more queries and extend the failure surface, so measure and design the batch rather than assuming event fidelity is free.

firstOrCreate() and updateOrCreate() also deserve precision. Their convenient read-then-write shape does not, by itself, serialize concurrent requests. Enforce uniqueness with a database unique constraint and handle a conflicting insert, or use an appropriate atomic upsert when its event and return-value semantics fit the operation.

A local scope names a reusable query constraint and keeps query intent composable. Laravel 13 documents the #[Scope] attribute on protected methods; older code commonly uses the scopePublished naming convention. A global scope automatically constrains every new model query and is useful for pervasive persistence policy such as soft deletion.

Scopes change queries, not stored rows or actor permissions. A surprising missing record may be excluded by a global scope, while an administrative path may remove that scope and expose a much larger dataset. Log the SQL and bindings or inspect the builder when behavior is unclear. For tenant isolation, a global scope is only defense in depth: raw database queries, explicit scope removal, jobs without context, and other storage paths can bypass it.

A repository can be worthwhile when it protects a domain from persistence vocabulary, centralizes a complex query contract, or supports a real alternate data source. A wrapper that mirrors every Eloquent method and returns Eloquent models creates ceremony without establishing a boundary. The decision trigger is whether callers gain a stable application-owned contract, not whether repositories are fashionable.

The model mechanics example puts a cast, mass-assignment allow-list, local scope, and after-commit observer next to two update paths. Its key review question is explicit: should a bulk transition intentionally bypass the observer, or must the operation load and save models (or produce an equivalent durable effect) instead?

Current (Laravel 13): PHP attributes can configure fillable/guarded state and scopes; casts(): array is the documented cast declaration; strict model diagnostics are available; and observers may implement ShouldHandleEventsAfterCommit.

Common (Laravel 11–12): $fillable/$guarded, a casts() method, Attribute accessors, observers, dirty tracking, and the instance-versus-bulk event distinction are all familiar. Conventionally named scopeX methods remain widespread.

Legacy: older models often use a $casts property and getFooAttribute/setFooAttribute methods. These are clues to storage transformation, not reasons for a mechanical rewrite. Characterize serialization, null, date/timezone, dirty-tracking, and event behavior with tests before migrating declarations.