Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ on:
push:
branches: [main]
pull_request:
# Grease overrides framework internals, so a quiet repo can still break: a Laravel patch
# release that changes an overridden method is only caught if CI runs against it. Weekly
# against the latest releases turns upstream drift into a red build within days.
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:

jobs:
test:
Expand Down Expand Up @@ -53,29 +59,40 @@ jobs:
- name: Parity smoke against real queries (the package's core promise)
run: php benchmarks/realworld.php 1

# Resolve the *lowest* versions our constraints allow, to prove the declared
# floor (illuminate ^12.0) is honest — Grease overrides framework internals, so a
# Resolve the *lowest* versions our constraints allow, to prove each declared
# floor (illuminate ^12.69.2 / ^13.33) is honest — Grease overrides framework internals, so a
# method that only landed in a later 12.x patch would slip through "highest" CI.
lowest:
name: Lowest deps · PHP 8.2 · Laravel 12
name: Lowest deps · PHP ${{ matrix.php }} · Laravel ${{ matrix.laravel }}
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
include:
- php: '8.2'
laravel: '12.*'
testbench: '10.*'
- php: '8.3'
laravel: '13.*'
testbench: '11.*'

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, pdo, sqlite, pdo_sqlite
coverage: none

- name: Pin Laravel & Testbench to the 12 floor
- name: Pin Laravel & Testbench to this major's floor
run: >
composer require
"laravel/framework:12.*"
"orchestra/testbench:10.*"
"laravel/framework:${{ matrix.laravel }}"
"orchestra/testbench:${{ matrix.testbench }}"
--no-interaction --no-update

- name: Install the lowest allowed dependencies
Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,44 @@ All notable changes to `grease` are documented here. The format is based on

## [Unreleased]

### Fixed

- **Hydrated models now carry the query model's runtime table and casts**, exactly as vanilla's
`newInstance()` does. The slim `newFromBuilder` built rows from class defaults, so
query-time casting (`Model::query()->withCasts([...])`) was silently ignored, and a runtime
`setTable()` on the model a query is built from (partitioned / archive tables) produced rows
whose `save()`/`delete()` targeted the *class* table. Both now match vanilla
(`SqlRoundtripTest`); the old "runtime `setTable()`" caveat is gone. A model with no `$table`
also gets its derived table name written onto hydrated rows, as vanilla does — memoized per
class during hydration (only when the model inherits vanilla `getTable()`; any override, e.g.
sharded or `AsPivot`, is called per row exactly as vanilla), so correct hydration costs ~5–10%
per row over the old (incorrect) path, not the ~80% a per-row `Str::pluralStudly()` would.
- **Upstream drift since the last CI run** — Laravel patch releases changed four overridden
methods, and Grease now mirrors each:
- `#[Refreshes]` (13.33) — the hydration snapshot dropped `$refreshes`, so every model after
the first per class silently lost refresh-after-write.
- `resolveClassAttribute()` (13.21 / 13.22) — the cache key now includes the property, and
attributes declared on traits are resolved.
- `Request::all()` (13.26) — input keys keep their position and win a collision with an
uploaded file. Probed once per process, since 12.x keeps the old order.
- `route()` parameters (12.69.2 / 13.31) — `%`, `?` and `#` inside a parameter are escaped
(`%25`/`%3F`/`%23`); the URL fast path emitted them raw, truncating the path.
- `ComponentAttributeBag::merge()` (12.63 / 13.19) — a `style` default is now terminated with
`;` before the component's own style is appended.
- **`HasGreaseWholeStateParityTest`** — the drift net that would have caught the above: it
compares a greased model's *entire* instance state against vanilla (cold, warm, hydrated) for
a model carrying every Eloquent class attribute, plus a tripwire that fails when Laravel adds
an attribute class the fixtures don't cover.

### Changed

- **Requires Laravel `^12.69.2 || ^13.33`** (was `^12.0 || ^13.0`). Grease overrides framework
internals; pinning to each major's current patch line lets it mirror the latest vanilla
behaviour instead of branching per patch release. `composer update` within your major.
- CI runs weekly on a schedule (so upstream drift fails the build within days, not at the next
push) and adds a lowest-deps leg for the Laravel 13 floor.
- The Livewire compatibility proof now also runs against Livewire 4.

## [0.9.0] - 2026-06-27

### Added
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ composer bench # phpbench per-op A/B + the SQL suite

## Requirements

PHP 8.2+, Laravel 12/13.
PHP 8.2+, Laravel 12.69.2+ or 13.33+ (the current patch line of each supported major — Grease
overrides framework internals and mirrors the latest vanilla behaviour of each).

## License

Expand Down
54 changes: 54 additions & 0 deletions benchmarks/LEADS.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,57 @@ the FPM bar.
a pass.
5. **Skip entirely:** the Pipeline/Kernel axis (FPM), the Support-primitives axis, and
every item in the negatives ledger.

---

## Follow-on leads (post-ship — added after the original research snapshot)

### ⏸ All-shallow except fast path for `CleanRequestInput` — SPIKED & PASSED (2026-06-29)
*Axis: HTTP (rider on the shipped `Grease\Http\Middleware\CleanRequestInput` tier) · Confidence: high (mechanism proven) · Status: parked, revisit on Linux + deep-content target*

**Premise.** `CleanRequestInput` fuses `TrimStrings`+`ConvertEmptyStringsToNull` into one
pass, but it still *inherits* `TransformsRequest::cleanArray`, which rebuilds the dotted
`$keyPrefix.$key` string at **every node** of the input tree (for the per-leaf except
match). On DEEP payloads (a ProseMirror/TipTap content post — `clean_input_ab.php`'s deep
arm is 1,125 leaves / depth 17) that per-node concat becomes the dominant *shared* cost —
the one thing the greased class doesn't fold, so its win-vs-vanilla ratio sags at depth
(−42% small → −35% deep). Lever: when every trim-except pattern is a **dot-free,
wildcard-free literal** (the default — `password`/`current_password`/`password_confirmation`),
`Str::is(pattern, K)` can only ever be true for a *top-level* key (a nested key contains
`.`, which a dot-free literal can never equal). So below depth 1 you can skip BOTH the
prefix build AND the except check entirely. Implementation: an all-shallow flag computed
once per request (`no '*' && no '.'` across the merged pattern list), then a `cleanTop`
(except-check vs bare key) / `cleanDeep` (trim+null only, no key, no prefix) walk split;
non-shallow except sets fall back to the inherited full-prefix `cleanArray`.

**Measured (macOS, JIT on — `scratchpad/shallow_skip_spike.php`, throwaway):**

| payload | leaves / depth | vanilla | shipped greased | spike (shallow-skip) |
|---|---|---|---|---|
| small | 10 / 2 | 8.16µs | −41.6% | −42.6% (**−1.7%** vs shipped) |
| wide | 100 / 3 | 71.73µs | −43.6% | −45.0% (**−2.4%** vs shipped) |
| deep document | 1125 / 17 | 1214.72µs | −31.0% | −37.3% (**−9.1%** vs shipped, ~76µs/req) |

**Parity: byte-identical, including the case with teeth.** Adversarial payload — a key
literally named `password` at depth 2 (vanilla builds `user.profile.password`,
`Str::is('password', …)` is false → it gets trimmed; the shallow path skips the except
check at depth → also trims it) plus a top-level `password` (preserved) and a nested
`current_password` (trimmed → empty → null). All three arms produce identical bytes:
`{"password":" top-secret ","user":{"profile":{"password":"not-excepted-here","current_password":null}},"rows":[{"password":"x"}]}`.
The flag is recomputed per request from live `$except`+`$neverTrim`, so a runtime
`except()` adding a wildcard/dotted pattern correctly drops to the fallback walk.

**Why PASSED for now (not a dead end — a deferred ship candidate):**
- It's a **−9% on a slice-of-a-slice**: only fires on deep payloads, buys back ~76µs of a
0.76ms operation. Worth it *if* deep-content endpoints (CMS bodies, rich-text comments)
are a real target; marginal on API-shaped benches.
- **macOS numbers** — per NOTES the −9.1% *ratio* travels better than the absolute µs, but
the 76µs could shrink on Linux to where it doesn't justify the cost.
- **Cost to ship:** a third overridden `TransformsRequest` method (`cleanParameterBag` +
`cleanTop`/`cleanDeep` reimplements the recursive walk) = new parity surface to carry,
on top of the already-shipped `transform` override.

**Revisit trigger:** confirm the ratio on Linux/`nas`, AND a deep-content endpoint becoming
a stated target. The spike is throwaway (scratchpad, not committed); the deep-payload bench
arm that surfaced it lives in `benchmarks/clean_input_ab.php`. Mechanism is fully captured
above — rebuildable from this entry.
57 changes: 57 additions & 0 deletions benchmarks/class_attribute_ab.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

/**
* A/B: warm `resolveClassAttribute()` — vanilla's `"$class@$attr@$property"` concat key vs the
* greased `[class][attr][property]` carve-out. Mirrors the booters' real call mix (a handful of
* attributes, mostly with a property, mostly absent → null).
*
* php benchmarks/class_attribute_ab.php [iterations]
*/

require __DIR__.'/../vendor/autoload.php';

use Grease\Concerns\HasGreasedClassAttributes;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Attributes\Visible;
use Illuminate\Database\Eloquent\Model;

#[Table(name: 'ab_widgets')]
class AbVanillaCA extends Model {}

#[Table(name: 'ab_widgets')]
class AbGreasedCA extends Model
{
use HasGreasedClassAttributes;
}

$calls = [
[Table::class, 'name'], [Table::class, null], [Fillable::class, 'columns'], [Guarded::class, 'columns'],
[Hidden::class, 'columns'], [Visible::class, 'columns'], [Appends::class, 'columns'], [Table::class, 'timestamps'],
];

$n = (int) ($argv[1] ?? 400_000);

$run = function (string $class) use ($calls, $n): float {
$fn = Closure::bind(fn ($a, $p) => static::resolveClassAttribute($a, $p), null, $class);
foreach ($calls as [$a, $p]) {
$fn($a, $p); // warm
}
$t = hrtime(true);
for ($i = 0; $i < $n; $i++) {
foreach ($calls as [$a, $p]) {
$fn($a, $p);
}
}

return (hrtime(true) - $t) / ($n * count($calls));
};

for ($r = 0; $r < 5; $r++) {
$v = $run(AbVanillaCA::class);
$g = $run(AbGreasedCA::class);
printf("vanilla %.1fns greased %.1fns %+.1f%%\n", $v, $g, ($g / $v - 1) * 100);
}
106 changes: 101 additions & 5 deletions benchmarks/clean_input_ab.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
* fused one pass, except-merge hoisted, but still Str::is per leaf
* fused+hybrid one pass + the compiled hybrid except matcher (Str::is gone)
*
* Output is identical (" " --trim--> "" --null), parity-gated before any timing. Two payloads:
* a realistic ~10-leaf request and an input-heavy ~100-leaf one.
* Output is identical (" " --trim--> "" --null), parity-gated before any timing. Three payloads:
* a realistic ~10-leaf request, a wide ~100-leaf one, and a DEEP ProseMirror-style content post
* (the case that actually motivates this — vanilla's per-node dotted-prefix concat grows with
* nesting depth, which the wide payload never exercises).
*
* php -d xdebug.mode=off -d opcache.jit=tracing -d opcache.enable_cli=1 -d memory_limit=1G \
* benchmarks/clean_input_ab.php [iters]
Expand Down Expand Up @@ -98,6 +100,83 @@ function largePayload(): array
return $p;
}

/**
* A realistic *deep* content post: a ProseMirror/TipTap/Lexical-style rich-text document, the
* shape a blog post / CMS body / comment editor actually submits. Unlike largePayload (wide and
* shallow, depth ~2), this is genuinely NESTED — doc → block → (nested list →)* → text node →
* mark → attrs — so it exercises the part of vanilla's cost that grows with DEPTH: every node
* rebuilds the dotted `$keyPrefix.$key` from an ever-longer accumulated string. Deterministic.
*/
function deepDocumentPayload(): array
{
// A text node carrying marks (links/styles) with their own attr bags — the deep leaves.
$text = function (string $t, bool $linked = false) {
$node = ['type' => 'text', 'text' => $t];
if ($linked) {
$node['marks'] = [[
'type' => 'link',
'attrs' => ['href' => ' https://example.test/x ', 'title' => '', 'target' => ' _blank '],
]];
}

return $node;
};

// A paragraph block: a few text runs, some whitespace-padded, some empty (→ null), some linked.
$paragraph = function (int $i) use ($text) {
return [
'type' => 'paragraph',
'attrs' => ['textAlign' => $i % 3 === 0 ? ' left ' : '', 'indent' => $i % 4],
'content' => [
$text(" Sentence $i with a fair bit of text to walk. "),
$text(' ', $i % 2 === 0),
$text('', true),
$text("clean-run-$i"),
],
];
};

// A bullet list whose items each hold a paragraph AND a nested sub-list — the depth driver.
$nestedList = function (int $section) use ($paragraph) {
$items = [];
for ($li = 0; $li < 3; $li++) {
$items[] = [
'type' => 'listItem',
'attrs' => ['checked' => $li % 2 === 0],
'content' => [
$paragraph($section * 10 + $li),
[
'type' => 'bulletList',
'content' => [[
'type' => 'listItem',
'content' => [$paragraph($section * 100 + $li)],
]],
],
],
];
}

return ['type' => 'bulletList', 'content' => $items];
};

$content = [];
for ($s = 0; $s < 8; $s++) {
$content[] = ['type' => 'heading', 'attrs' => ['level' => ($s % 3) + 1], 'content' => [
// headings carry a single text run; some padded, some empty
['type' => 'text', 'text' => $s % 2 === 0 ? " Heading $s " : ''],
]];
$content[] = $paragraph($s);
$content[] = $nestedList($s);
}

return [
'title' => ' My Post Title ',
'slug' => '',
'status' => 'draft',
'body' => ['type' => 'doc', 'attrs' => ['version' => 1], 'content' => $content],
];
}

$trim = new TrimStrings;
$convert = new ConvertEmptyStringsToNull;
$fused = new FusedCleanInput;
Expand Down Expand Up @@ -126,13 +205,30 @@ function largePayload(): array

echo 'jit: '.((opcache_get_status(false)['jit']['on'] ?? false) ? 'YES' : 'no')." iters=$iters\n";

$payloads = ['small' => smallPayload(), 'large' => largePayload()];
$payloads = [
'small' => smallPayload(),
'large (wide)' => largePayload(),
'deep document' => deepDocumentPayload(),
];

// Max nesting depth of an array tree — the dimension largePayload doesn't exercise.
$maxDepth = function (array $a) use (&$maxDepth): int {
$d = 1;
foreach ($a as $v) {
if (is_array($v)) {
$d = max($d, 1 + $maxDepth($v));
}
}

return $d;
};

foreach ($payloads as $label => $payload) {
$leaves = 0;
array_walk_recursive($payload, function () use (&$leaves) {
$leaves++;
});
$depth = $maxDepth($payload);

$mk = function () use ($payload): Request {
$r = Request::create('/x', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($payload));
Expand Down Expand Up @@ -169,10 +265,10 @@ function largePayload(): array
$fClean = max(0, $fT - $baseline);
$hClean = max(0, $hT - $baseline);

echo "\n== $label payload: $leaves leaves — clean cost (request-build baseline subtracted) ==\n";
echo "\n== $label payload: $leaves leaves, depth $depth — clean cost (request-build baseline subtracted) ==\n";
printf(" %-22s %s\n", 'vanilla (trim+convert)', $us($vClean));
printf(" %-22s %s (%s vs vanilla)\n", 'fused (Str::is)', $us($fClean), $pct($vClean, $fClean));
printf(" %-22s %s (%s vs vanilla, %s vs fused)\n", 'fused + hybrid', $us($hClean), $pct($vClean, $hClean), $pct($fClean, $hClean));
}

echo "\nParity: OK — fused & fused+hybrid byte-identical to vanilla two-pass on both payloads.\n";
echo "\nParity: OK — fused & fused+hybrid byte-identical to vanilla two-pass on all payloads.\n";
Loading
Loading