Caveats & Narrowing
Grease buys speed by removing the machinery that preserves flexibility nobody uses. This page is the complete, honest accounting of what that costs. The short version: two obscure things change on a greased model's cast path, and both have a trivial idiomatic workaround.
The full cast contract is asserted byte-identical to vanilla in the test suite — every cast type, every edge value, every null, every dirty comparison — across PHP 8.2–8.5 and Laravel 12/13.
The guiding principle
Eloquent gives you a hundred ways to configure a model, and vanilla pays — on every row — to keep all of them live at once. Grease optimizes the 95–99% case: a model whose table, primary key, casts, and date format are declared as class properties, and an app that reads input and config through the framework's own APIs. For that case the output is byte-identical and the suite proves it.
If you do unusual runtime surgery — reassigning a model's table or date format per instance after it's already in use, or writing directly to the Symfony input bags or the config array behind the framework's back — you've stepped outside the case Grease caches, and a tier may serve a value computed before your change. None of these are normal Eloquent; each has an idiomatic, class-level equivalent that Grease handles perfectly. Rule of thumb: if you're doing something unusual with your models or the request, run your own tests with Grease enabled. The narrowings below are the complete, honest list of where that matters.
What stays exactly the same
- Custom casts (
CastsAttributes), the documented extension point — unchanged. getCastType()overrides — a subclass that definesgetCastType()shadows the trait and stays fully live. The resolved type is otherwise memoized per class (it's a pure function ofgetCasts()), exactly likegetCasts()itself.- Enum casts — accelerated, with conversion delegated to the framework so output is byte-identical.
mergeCasts()/withCasts()at runtime — fully honored; the per-class cache steps aside for a mutated instance.
The two narrowings
1. Per-instance $casts set in a constructor isn't supported
The cast map is cached per class. If you assign a different $casts per instance inside a model's constructor, a greased model would serve the first instance's map.
Workaround: use mergeCasts() / withCasts() at runtime instead — these are honored, because the divergence guard detects the change and steps the cache aside.
// instead of mutating $this->casts in a constructor:
$model->mergeCasts(['detail' => 'array']);This pattern is vanishingly rare in real apps.
2. A per-key isEncryptedCastable() override isn't honored
Overriding that undocumented internal — to encrypt an attribute whose cast type isn't itself an encrypted:* type — won't decrypt on a greased model.
Workaround: use the idiomatic encrypted cast, which works perfectly:
protected $casts = ['ssn' => 'encrypted:string'];This is an undocumented internal — there's no idiomatic reason to override it.
3. Reassigning a model's table or date format at runtime
The hydration and date tiers treat a model's table and date format as class-level — which they are in essentially every app (protected $table, protected $dateFormat). Two narrow edges fall out of that:
setTable()on the model a query is built from isn't carried onto the rows it hydrates — they get the class-default table. (Setting$tableas a class property, or on a freshly-constructed model you then query, both work.)setDateFormat()called per instance after the date fast-path has been certified for the class can leave that instance using the previously-certified plan.
Both are vanishingly rare — the standard protected $dateFormat / protected $table declarations are read correctly and cached once. Toggling timestamps ($model->timestamps = false / withoutTimestamps()), changing the primary key (setKeyName() / setKeyType() / setIncrementing()), and runtime mergeCasts() / withCasts() are all normal and fully handled — only the two runtime reassignments above step outside the cache.
What defers to vanilla (correct, just unaccelerated)
Acceleration is never bought with correctness. Where Grease can't certify byte-identity, it hands the work back to the framework:
- Class-castable reads (
CastsAttributes) — already object-cached by Eloquent after first access, so there's little left to win; deferred. - Encrypted reads — dominated by decryption; the dispatch shave would be noise, and reproducing decrypt-then-recast is the most error-prone path in the file. Deferred.
- Exotic date serialization — non-UTC default serializers, custom date formats,
date/immutable_datecasts, sub-second or non-string values. The serialization tier's per-value shape guard defers these to vanilla automatically. - Custom
using()pivots andmorphToManypivots — the greased pivot (HasGreasedPivots) only substitutes the defaultPivot. A relation that declaresusing(CustomPivot::class)keeps that class, andmorphToManybuilds itsMorphPivoton the relation (bypassing the model'snewPivotseam) — both stay vanilla, unaccelerated.
All of the above produce identical output; they simply don't get the fast path.
The decimal-cast trait (HasGreasedDecimalCasts) is separate on purpose
decimal:N casting has its own opt-in trait, deliberately kept out of HasGrease because decimal usually means money. Two things to know if you reach for it:
- It never rounds. The fast path fires only on a value already at the exact target scale; any value that would need rounding or reformatting defers to Brick\Math, exactly as vanilla. The worst case on any input is "no speedup", never a wrong number.
- The win is driver-gated (correctness is not). MySQL/PostgreSQL return canonical decimal strings, so it fires; SQLite returns floats, so it defers, byte-identical, with no speedup. It also does nothing for a model with no
decimalcasts — it's a narrow tier for decimal-dense financial models, not a default.
The acyclic-serialization trait (HasGreasedAcyclicSerialization) has one real responsibility
This trait drops Eloquent's circular-reference guard from toArray/getQueueableRelations/ touchOwners/push — the only Grease tier that asks something of you in return:
- It is byte-identical for acyclic data. The guard only ever changes output when a method re-enters the same object — a cycle. With no cycle it is pure overhead, so removing it returns exactly what vanilla returns.
- A cyclic graph is unsupported and will recurse until the stack overflows. There is no guard left to break the loop — that is the entire point of the opt-in. Leave it off self-referential tree models (adjacency lists with
parent+childreneager-loaded), polymorphic graphs that can point back, or anywhere you wire relations into a loop by hand. When unsure, don't add it; the guard stays, byte-identical and safe.
The foundation tiers (container, request, config, router & view index)
These are a different axis from the model traits, with their own opt-in — see The Container, The Request, The Config Repository, The Router, and The View Cache. Their narrowing is minimal:
- Container — none beyond the opt-in itself. The blueprint caches reflection, not resolution, so contextual bindings,
$withoverrides, and late rebinds all stay live; the resolved object graph is asserted byte-identical to vanilla. - Request — exactly one carve-out: direct mutation of an input-source bag after the first input read —
$request->query->add(...),$request->request->set(...),$request->json()->set(...). Use the Laravel-level mutators (merge(),$request['key'] = …) instead; the memo tracks those, and the lifecycle paths (clone/duplicate,initialize,setMethod) too. Mutating bags outside the input surface is fully safe — including$request->attributes->set(...), the common middleware pattern, andcookies. - Config — exactly one carve-out: out-of-band mutation of
$items, a macro or reflection writing the protected array directly instead of going throughset()(which the caches track, along withprepend/push/offsetSet/offsetUnset). Vanishingly rare;flushConfigMemo()is the explicit hook if you ever do it. Thegrease:config-cacheflat index additionally only engages while it's fresh relative to the config cache — a later plainconfig:cacheorconfig:cleardisables it automatically, so a stale index is never served. - Router — the lazy middleware cache has no real carve-out (the only one, a direct
$router->middlewarePriority = …write bypassing the registration methods, can't bite in practice — Laravel's ownsyncMiddlewareToRouter()immediately re-syncs aliases/groups, which flush, and runs before dispatch). The eagergrease:route-cacheindex adds exactly one contract: the alias/group/priority maps must be the same at build time and run time. Concretely, don't gate middleware registration on the environment — a provider that registers middleware only underrunningInConsole(), or an env/flag-conditional alias likethrottle→ Redis-vs-sync, would make the cached resolution disagree with serving. Rebuild on every deploy; the freshness guard disables a stale index after any plainroute:cache/route:clear/config:cache(whileoptimizerebuilds it, runninggrease:route-cache), and it's inert in development (no route cache) — so a wrong list is never served, you only lose the eager win until you re-rungrease:route-cache. A route whose middleware is assigned dynamically simply misses the index and resolves live. - View index — the lazy Blade greasing (the
view/blade.compilerswaps) has no carve-out. The eagergrease:view-cacheindex adds the same one contract as the others: build == runtime — it's a deploy artifact, so rebuild it on deploy. A structural view change (add / move / delete) needs a rebuild, exactly likeview:cacheitself (in-place edits don't — the name→path mapping is unchanged and recompilation stays with the framework). The freshness guard disables a stale index after any plainview:cache/config:cache(whileoptimizerebuilds it, runninggrease:view-cache); it's inert in development (no artifact); and a name the index doesn't contain — added, dynamic, or non-Blade — resolves live, byte-identical. So a wrong path is never served.
All five are opt-in independently of everything else. Not confident? Don't take them — the model, event, and Blade tiers all work without them.
Want zero cast caveats at all?
Use the tiers à la carte and skip the cast-metadata tiers. You keep the hydration win — which carries no behavioural narrowing — and the cast path stays 100% vanilla:
use Grease\Concerns\HasGreasedHydration;
class User extends Model
{
use HasGreasedHydration; // construct / hydration
}That's the design philosophy in one snippet: opt in to exactly the speed you want, keep exactly the flexibility you use.