onelearningcommunity/grease
Grease is a menu of independent, byte-identical performance tiers for Laravel's hot paths—Eloquent hydration, casting and serialization, events, Blade, config, validation, the container and router. Opt in per tier; the model trait is the zero-config starting point. Zero framework changes, and anythi...
A correctness release — the last stop before 1.0. Laravel patch releases since 0.9.0 changed several methods Grease overrides, and a deep audit found two hydration bugs. All fixed, each with a parity test that fails without the fix.
CI: full parity matrix (PHP 8.2–8.5 × Laravel 12/13 + lowest-deps on both floors) green on the tagged commit.
^12.69.2 || ^13.33Grease overrides framework internals, so it now tracks each supported major's current patch line and mirrors its latest vanilla behaviour. Composer won't offer 0.9.1 below the floors — composer update within your major first.
Model::query()->withCasts([...])) was ignored on greased rows — now applied, as vanilla.setTable() on the model a query is built from (partitioned / archive tables) produced rows whose save()/delete() hit the class table — rows now carry the query model's table. The old caveat is gone.$table) get their derived name written onto hydrated rows like vanilla, memoized per class so it stays cheap; any getTable() override (sharded, AsPivot, yours) is called per row, as vanilla.#[Refreshes] (13.33) — was dropped after the first model per class.resolveClassAttribute() (13.21/13.22) — property-keyed cache; attributes on traits are read.Request::all() (13.26) — input keys keep position and win a collision with an upload (12.x keeps the old order; probed once per process).route() parameters (12.69.2 / 13.31) — % ? # are escaped; the URL fast path emitted them raw, truncating the path. If you use GreaseRoutingServiceProvider on 12.69.2+, upgrade.ComponentAttributeBag::merge() (12.63 / 13.19) — a style default is terminated with ;.HasGreaseWholeStateParityTest compares a greased model's entire instance state to vanilla (cold / warm / hydrated), with a tripwire that fails when Laravel adds an Eloquent attribute class.See the CHANGELOG.
Three byte-identical drop-ins for the HTTP request path — kept off the main tier menu as Conditional Helpers because the "condition" is whether the speedup materialises for your traffic, never about correctness.
CI: full parity matrix (PHP 8.2–8.5 × Laravel 12/13 + lowest-deps) green on the tagged commit.
CleanRequestInput — one input walk instead of twoThe default stack runs TrimStrings and ConvertEmptyStringsToNull, each recursively walking and rebuilding the whole input tree (and TrimStrings runs Str::is() on every string leaf). The fused middleware does both transforms in one walk, hoists the except-merge out of the leaf loop, and matches excepts through a compiled CompiledPatternSet.
CleanRequestInputParityTest).bootstrap/app.php.skipWhen isn't reproduced — keep the stock pair if you rely on it.CompiledPatternSet — amortised Str::is() for many valuesAn array-aware matcher compiled once into the fastest faithful Str::is($patterns, $value) equivalent: literals to an isset() hash, bare * to a short circuit, wildcards to one merged alternation regex.
Str::is — the compile only pays off amortised.Request::is() (ships with the Request tier)Vanilla recompiles Str::is and recomputes decodedPath() per pattern, per call; the override memoises the decoded path and matches through a per-worker-cached CompiledPatternSet.
See the CHANGELOG and the Conditional Helpers guide for details.
Two new standalone, byte-identical model opt-ins — both deliberately kept out of HasGrease because each is either narrow or carries a promise only you can make.
CI: full parity matrix (PHP 8.2–8.5 × Laravel 12/13 + lowest-deps) green on the tagged commit.
HasGreasedAcyclicSerialization — drop the recursion guardEloquent runs a debug_backtrace circular-reference guard on every toArray, getQueueableRelations, touchOwners, and push — a 100% tax for a <1% (self-referential-tree) problem ordinary eager loading can't even produce. One withoutRecursion() override removes it for a model that promises an acyclic graph.
with() list response (Linux, JIT) — the most common API shape.HasGreasedDecimalCasts — skip Brick\Math for canonical decimalsdecimal:N reads round-trip through BigDecimal::of()->toScale() per row. When the stored value is already a canonical scaled string, that's the identity — so return it verbatim and skip Brick.
benchmarks/stack_excimer.php — an Excimer profiler over the fully-greased cumulative stack (how both of these were found).See the CHANGELOG for details.
A sixth opt-in axis lands on the routing tier: Grease\Routing\UrlGenerator, an eager-index fast path for route().
Vanilla re-runs the full RouteUrlGenerator::to() assembly on every call — formatParameters / replaceRouteParameters / replaceNamedParameters / addQueryString / the optional-param walk, over a thick Arr/Collection layer. None of that is the Symfony route compile (that's cached on Route::$compiled and persisted through route:cache) — it's pure per-call string assembly, paid thousands of times in a link-heavy API-Resource payload for a URI shape that never changes.
The tier caches each route's class-pure [segments, params] shape (lazily, or pre-seeded by grease:route-cache) and replays it as a concat.
route() callroute(): in the cumulative stack, api_resource.json goes −74% → −82% and api_resource.blade −28% → −54%, while non-route() routes stay within noiseAbsolute (the route() default) and relative are both accelerated, reusing vanilla's memoized formatRoot()/formatScheme(). Anything the fast path can't prove identical falls through to parent:: — domain / optional {param?} / scoped {param:field} / route-default / duplicate-name routes, extra params (query string), arity mismatches, empty-string / non-scalar values, signed URLs, and any URL::defaults()/formatHostUsing()/formatPathUsing() customization. The relative path derives from the full absolute URI and strips exactly what vanilla strips, so forceRootUrl()/useOrigin() root paths and subdirectory base paths are preserved.
Register Grease\Routing\GreaseRoutingServiceProvider — it swaps the lazily-resolved url singleton (no bootstrap/app.php edit; signed URLs and route-cache rebinding keep working). grease:route-cache now writes the URL shape index alongside the middleware index.
Verified by UrlGeneratorParityTest (oracle = vanilla across absolute/relative, every defer case, encoding, exceptions, secure scheme, subdirectory + forced-root apps, prewarm-seed parity). Full matrix green on PHP 8.2–8.5 × Laravel 12/13.
Full changelog: https://github.com/One-Learning-Community/grease/blob/main/CHANGELOG.md
The greased-pivot feature release. Byte-/behaviour-identical to vanilla, Linux-confirmed, and survived a three-agent adversarial parity audit.
Greased many-to-many pivot (Grease\Eloquent\Pivot + HasGreasedPivots, folded into HasGrease). The pivot a belongsToMany hydrates per related row is a "dynamic model" the framework builds internally — and it never carried Grease's tiers, so a pivot-heavy belongsToMany()->get() paid the full per-row cost (the initialize* booters, resolveClassAttribute, and the timestamp Carbon round-trip on withTimestamps() pivots) the model tiers exist to remove. It was the one model class Grease structurally couldn't reach.
get() (Linux/NAS).HasGrease — no API change.using(CustomPivot::class) relation keeps that class, and morphToMany pivots stay vanilla MorphPivot — both unaccelerated but byte-identical.__call dispatch verdict memoized — a standalone per-model opt-in (use HasGreasedQueries;), deliberately not bundled into HasGrease: it's byte-identical but swaps a custom builder in app-wide for a sub-0.1%-of-a-request gain, so it's reserved for query-construction-heavy paths.wrapTable() identifier quoting memoized alongside the shipped wrap() memo — a sub-µs/query rider that compounds under Octane.LICENSE, composer.json, and src/ — a smaller install.589 tests / 1648 assertions green (PHP 8.2–8.5 × Laravel 12/13), confirmed on Linux. The audited edge cases — pivot write-path, using/morph defers, __call precedence/STI/macros — are locked as regression guards.
Full notes in the CHANGELOG.
A byte-identical hardening release. A full parity audit of the whole package surfaced four silent output divergences from vanilla, plus a few normal-usage cache-staleness edges. No API changes; every fix ships with a regression test that fails on the old behaviour, and the dispatch path actually came out faster.
Event dispatcher: interface listeners are no longer dropped on a string-dispatched class
event. The no-listener fast path gated on hasListeners(), which doesn't see interface
listeners — so dispatch(SomeEvent::class) whose only listener was bound to an interface it
implements silently fired nothing (vanilla fires it via addInterfaceListeners()). The fast
path now also rules out interface listeners, gated on a boot-time flag set only when an
interface-named listener is registered — so apps without them pay nothing, and no-listener
dispatch is faster than before (the flag replaces a per-dispatch reachability check).
Casts: an enum that also implements Castable now routes to its castUsing() cast. The
enum fast path gated on enum_exists() alone; vanilla's isEnumCastable() excludes Castable
subclasses, so such an enum returned its raw case instead of the cast's output. Now mirrored —
the extra check is memoized per cast type, so zero hot-path cost.
Serialization: out-of-range / zero dates defer to vanilla instead of being rewritten
verbatim. The date fast path's shape check was purely syntactic, so a legacy MySQL
0000-00-00 00:00:00 (or 2026-02-30) was emitted as-is while vanilla overflow-normalizes it
through Carbon (-0001-11-30). The guard now also confirms the date is real (checkdate + time
ranges) before fast-pathing; valid dates still skip the Carbon round-trip. Same fix on the
fromDateTime() write path, which feeds the save() dirty check.
Serialization: toArray() no longer drops a relation an appended accessor lazily loads. The
relation-less short-circuit checked relations === [] before running accessors — but an
$appends accessor reading $this->someRelation loads it as a side effect, which vanilla emits
(relations serialize after attributes). The short-circuit now applies only if relations are still
empty after the attributes are built.
Attributes: getDates() is no longer poisoned by a timestamps-disabled instance. The
per-class cache ignored $model->timestamps = false / withoutTimestamps(), so a timestamps-off
instance could blank the cache for timestamps-on instances of the same class (dropping
created_at/updated_at from their output). Now keyed by usesTimestamps().
Attributes: runtime setKeyName() / setKeyType() / setIncrementing() invalidate the cast
cache. These feed getCasts()'s key entry; the per-class cache now steps aside for an instance
that mutates them at runtime, exactly like mergeCasts() / withCasts().
Request: isJson() is read live, not memoized. A content-type header changed after
construction (content-negotiation middleware) left the memo stale; it now delegates to vanilla
every call — the input-base memo already keeps it off the hot path.
setTable() on a
query prototype) or date format (setDateFormat() per instance) after it's in use — on the
Caveats page, alongside a new
guiding principle: Grease optimizes the 95–99% case; if you do something unusual with your models
or the request, run your own tests with Grease enabled.The performance surface roughly doubles. The Eloquent model trait grows from four tiers to seven and gains three byte-identical short-circuits on the hot read/write paths; a full drop-in Blade view Factory joins the compiler as a second render-path axis; the event dispatcher's view-event guard is memoized; and every published benchmark number is now generated live from the parity-gated harness. Output stays byte-identical to vanilla throughout — the parity suite grows to 317 tests / 867 assertions.
Three new Eloquent model tiers — HasGrease now composes seven, each a method
override that reads the per-class blueprint and falls back to parent:::
HasGreasedClassAttributes — caches Model::resolveClassAttribute() (the
#[Table]/#[Fillable]/#[Hidden]/#[Appends]/… lookups the per-instance
initialize* booters run ~13× per new model) behind a concat-free
[class][attribute] carve-out, replacing vanilla's freshly-built "$class@$attr"
cache key. Byte-identical bug-for-bug, including vanilla's property-less cache-key
collision quirk. −13% on a 2,100-row eager get().HasGreasedInitializers — freezes the four surviving initialize* trait booters
(guards / hides / timestamps / touches) per class: the cold path runs parent:: once
and snapshots the resulting properties; warm instances apply the snapshot by copy,
eliminating the resolveClassAttribute() calls entirely. −8.4% on the eager
get(), on top of the cache above.HasGreasedCastProbes — memoizes the per-key cast-classification probes
(isEnumCastable / isClassCastable / isClassSerializable) that
addCastAttributesToArray() reruns on every row during toArray(). The verdict is a
pure function of the cast type; memoized per [class][probe][key] (with
array_key_exists, since the common verdict is false), reusing the casts-divergence
guard. −10% on a rich-cast get()->toArray().A greased Blade view Factory (Grease\View\Factory) — a second Blade render-path
axis, bound as the view singleton by GreaseViewServiceProvider alongside the
compiler. It overrides the hottest, allocation-shaped frames on the render path, each
byte-identical and gated by a parity-asserted macro:
[@foreach](https://github.com/foreach) $loop bookkeeping — an in-place by-ref index update instead of an
array_merge of the 10-key state every iteration (−27% on a $loop-heavy table);[@yield](https://github.com/yield) — one preg_replace_callback over three non-overlapping markers in place of
vanilla's three str_replace scans of the whole section (−19% on a layout);[@push](https://github.com/push)/[@prepend](https://github.com/prepend) stack assembly — drops the per-pop tap() closure (−18% on
asset stacks);[@props](https://github.com/props) components build the greased
ComponentAttributeBag from the start, and getCompiledPath() memoization.Grease\View\ComponentAttributeBag — a ComponentAttributeBag subclass whose
merge() is two plain foreach loops instead of vanilla's ~5-allocation Collection
pipeline (partition/mapWithKeys/->merge/->all). The compiler hands components
this subclass, so the $attributes->merge([...]) nearly every component runs takes the
fast path; merge() returns new static to stay greased down a chain. Together with
the compiler and Factory: −38.7% simple / −30.6% rich on a 1,000-component render.
Live benchmark pipeline — bash benchmarks/export-metrics.sh runs every family on
the canonical Linux Docker image and writes a single parity-gated
docs/.vitepress/data/benchmarks.json that the docs (and the README's one-line summary)
render from. The published numbers are now exactly what the harness measured, and a
PARITY FAIL in any family aborts before its JSON is written.
A greased Blade compiler (Grease\View\Compiler) — a new opt-in tier that rewrites
one emit, [@props](https://github.com/props) resolution, the per-render hot path every component pays. Vanilla
compiles [@props](https://github.com/props) to a 20-line block that, on each render, rebuilds a flat name list
(−14%** on a full
1,000-anonymous-component render (ComponentAttributeBag::extractPropNames), partitions incoming attributes with
in_array (a linear scan per attribute), evaluates the [@props](https://github.com/props) array literal twice
(once for the names, once for the array_filter that finds the defaults), allocates a
second attribute bag, and snapshots the whole scope with get_defined_vars() to unset
attribute-named locals. The greased emit collapses all of that into one call —
Grease\View\Props::mergeAttributes($site, $decl, $attributes) — that partitions,
applies defaults, and returns a single map (the resolved prop locals plus attributes,
the surviving bag) which a tight $$key = $value loop binds into scope. The name set
and which keys carry defaults are memoized per [@props](https://github.com/props) site (compile-time constants),
so only fresh default values are read each render; the declaration is evaluated once;
and the loop (not extract, which is slower and skips non-identifier keys) reproduces
vanilla's locals exactly — including the inaccessible ${'icon-name'} kebab-alias
local. Bind it by registering the (non-auto-discovered)
Grease\View\GreaseViewServiceProvider, which extends blade.compiler via
Compiler::fromBase() (registered directives/components carry over). Behaviour-
identical, asserted A/B against the stock compiler across declaration/attribute
scenarios (execution parity, incl. get_defined_vars). **benchmarks/blade.php, Taylor's 2024 challenge),
holding across simple and prop-heavy components — a real, free, parity-safe slice of
every component render that compounds with the other tiers.
Narrowing: the declaration is evaluated once, not twice — so a non-deterministic
default (e.g. [@props](https://github.com/props)(['id' => uniqid()])) yields one value rather than vanilla's
second-evaluation value. Byte-identical for deterministic defaults (the norm).
fill([]) — the no-op that __construct runs on every hydrated row (via
newFromBuilder's new static) still computed totallyGuarded() and
fillableFromArray([]) up front. HasGreasedHydration now returns $this early on
[]. −5% on an eager get().toArray() — vanilla wraps every toArray() in withoutRecursion()
(a debug_backtrace + Onceable trace-hash + WeakMap, to guard circular relations).
With no relations loaded there is nothing to recurse into, so HasGreasedSerialization
returns attributesToArray() directly; any loaded relation defers to parent::, guard
intact. Byte-identical, including the circular-relation case. −27% on a relation-less
get()->toArray() (index endpoint −81% → −86%).fromDateTime() on dirty-checks — originalIsEquivalent() on save() compares a
date column as fromDateTime($attr) === fromDateTime($original), where the operands are
storage strings — so vanilla parses each into Carbon and formats it straight back to the
identical string. A per-class probe certifies the round-trip and the fast path returns
the string as-is. −38% on a save()-heavy dirty-check.Grease\Events\Dispatcher::hasListeners() now memoizes its presence check per
event name, against the same cache the dispatch() fast path already used (reset on
listen()/forget(), so behaviour is identical). Previously only dispatch()
consumed the cache; the public hasListeners() re-scanned every wildcard on each
call. This matters because the framework fires view events through a hasListeners()
guard (ManagesEvents::callCreator/callComposer), not a bare dispatch() — so the
Blade/Livewire render path never reached the cache before. Re-rendering the same
components now costs one wildcard scan per distinct view name instead of one per
render: −92% on a Livewire-shaped render with realistic wildcards registered
(557 μs → 47 μs, new ViewEventBench).Two opt-in serialization helpers for code that builds its output array by hand
(Scout toSearchableArray, a JsonResource, an export) and so bypasses the
attributesToArray() date tier. Both are byte-identical to a named vanilla
expression; the parity suite grows to 225 tests.
greaseSerializeDate(string $key): ?string on HasGreasedSerialization — the
date-serialization tier's fast path exposed as a standalone primitive, so code that
hand-picks attributes (Scout toSearchableArray, a JsonResource, an export)
captures the date win without routing the whole model through attributesToArray().
Returns byte-identical output to serializeDate(asDateTime($this->attributes[$key]))
(the toJSON form), reusing the existing per-class probe — certified classes skip
the Carbon round-trip, everything else defers to the exact vanilla composition.
Eligible for timestamps and plain datetime / immutable_datetime casts;
−80% on the hand-pick date-serialization path (62.8μs → 12.3μs, fresh-hydrated).greaseSerializeOnly(array $keys): array on HasGreasedSerialization —
serialize a curated subset of a model to its array form, byte-identical to
Arr::only($model->attributesToArray(), $keys) but without serializing the columns
the filter would discard. The greased array path (date tier included) runs over the
narrowed set; the model's own visible/hidden config is honored and its visible
list is restored before returning (non-mutating, no clone). Picking 3 of 23 cast-
heavy columns: −87% vs the naive serialize-all-then-filter (175.8μs → 22.1μs), and a
statistical tie with mutating setVisible(...)->attributesToArray() — the win is the
skipped work plus non-mutation, so no per-key-set precompute was added (it had no
measurable headroom to recover).First release. Opt-in performance for Laravel's hot paths, with output asserted byte-identical to vanilla Eloquent across every cast type, edge value, and dirty-check.
composer require onelearningcommunity/grease
use Grease\Concerns\HasGrease;
class User extends Model
{
use HasGrease;
}
Four composable per-model tiers over a single lazily-built, per-class blueprint (HasGrease, or the GreasedModel base class):
HasGreasedHydration — removes the per-row ReflectionClass, the per-instance casts rebuild, and newInstance's redundant work during hydration.HasGreasedAttributes — memoizes getCasts / getCastType / getDates / mutator probes / getDateFormat; a divergence guard keeps runtime mergeCasts() / withCasts() correct.HasGreasedCasts — flyweight cast dispatch replacing the per-access switch, an enum fast path that delegates conversion to the framework, and synonym-cast flyweight dedup.HasGreasedSerialization — eliminates the Carbon parse-and-reformat round-trip for date serialization when a per-class probe certifies a byte-identical rewrite; defers to vanilla otherwise.Grease\Events\Dispatcher — a drop-in, behaviour-identical faster event dispatcher (no-listener fast path, cached listener resolution, pre-compiled wildcard patterns), opt-in via GreaseEventServiceProvider. Speeds up every dispatch app-wide, not just model events.
PHP 8.2+ · Laravel 12 / 13
📖 Documentation · 📋 Changelog
Built proudly with Claude.
How can I help you explore Laravel packages today?