DEV Community

Cover image for I Couldn't Fix the Bug Until I Stopped Supporting the Old Major
Nasrul Hazim
Nasrul Hazim

Posted on

I Couldn't Fix the Bug Until I Stopped Supporting the Old Major

TL;DRlaravel-scheduler-manager now targets Livewire 4 only. Not because Livewire 3 stopped working, but because one of the two Livewire 4 bugs had a fix that was unsafe on Livewire 3. Supporting both majors meant shipping neither fix. The compatibility shim wasn't costing me guard code — it was costing me the repair.


Where this left off

Yesterday I wrote about a constraint I shipped without verifying: the package pinned livewire/livewire to ^3.7 and the README said Livewire 4 was unsupported, citing two failures. Checking properly found one of them was real, one was a caching artefact, and that I couldn't tell them apart because compiled Blade views are cached keyed on the Blade source, not on the Livewire version that compiled them.

Today the pin moved the other way:

"require": {
    "livewire/flux": "^2.17",
    "livewire/livewire": "^4.0",
    "php": "^8.4"
}
Enter fullscreen mode Exit fullscreen mode

That's a feat! — a major bump. Applications still on Livewire 3 stay on the 1.0.x line, which keeps working and keeps getting fixes. What follows is why "support both" was never on the table.

Blocker one: a namespaced alias never reaches the component map

The package registers its screens under a scheduler-manager:: namespace so a host app can't collide with them. In Livewire 3, the obvious registration is enough:

foreach (static::COMPONENTS as $alias => $class) {
    Livewire::component($alias, $class);
}
Enter fullscreen mode Exit fullscreen mode

In Livewire 4 that alone leaves every screen unresolvable. The resolver short-circuits: Finder::resolveClassComponentClassName() returns null the moment it sees a :: in the alias, and never consults the explicit component map at all. Namespaced aliases are resolved exclusively through registered class namespaces. So you also need:

Livewire::addNamespace(
    'scheduler-manager',
    classNamespace: __NAMESPACE__.'\\Livewire',
);
Enter fullscreen mode Exit fullscreen mode

Fine — except addNamespace() only exists on Livewire 4. While ^3.7 was still a supported constraint, that call couldn't be written straight. It had to go through the facade root, because the static proxy wouldn't type-check against Livewire 3:

$manager = Livewire::getFacadeRoot();

if (is_object($manager) && method_exists($manager, 'addNamespace')) {
    $manager->addNamespace(/* ... */);
}
Enter fullscreen mode Exit fullscreen mode

Nine lines to say four lines' worth of thing, plus a comment explaining the contortion, plus a runtime method_exists() check that static analysis can't reason about. That's the visible tax of a compatibility shim, and honestly it's the affordable one. The next blocker is the expensive kind.

Blocker two: a fix that was only safe on the new major

Livewire 4 ships a precompiler, SupportCompiledWireKeys, that rewrites wire:key. It injects a <?php ?> block immediately before the attribute — which means inside the tag. On a plain HTML element that's harmless. On a Blade component tag it isn't, because the component compiler then emits invalid PHP:

syntax error, unexpected token "endif"
Enter fullscreen mode Exit fullscreen mode

Every <flux:*> tag carrying a wire:key blew up. The fix is a one-liner per occurrence: delete the attribute.

@foreach ($presets as $label => $expression)
    <flux:button
        size="sm"
        type="button"
        wire:click="applyPreset('{{ $expression }}')"
        :variant="$cron === $expression ? 'primary' : 'outline'"
    >
        {{ $label }}
    </flux:button>
@endforeach
Enter fullscreen mode Exit fullscreen mode

Livewire 4 derives loop keys itself — livewire.smart_wire_keys, on by default — so the manual attribute was redundant there.

But it is not redundant on Livewire 3. Livewire 3 has no such fallback, and a keyless loop is exactly how you get DOM-diffing bugs: rows that keep a stale child component after a re-sort, inputs that hold the wrong row's value. Deleting the attribute fixes major 4 and quietly introduces a class of bug in major 3.

So while both were supported, the honest options were: keep wire:key and stay broken on 4, or delete it and become subtly wrong on 3. There is no third option that isn't a runtime version sniff inside a Blade template, which I'm not doing to a package other people have to read.

That's the real cost of supporting two majors. Not the guard clauses. The repairs you can't make because the fix is only correct on one side of the fork.

Making the deletion stick

A one-line deletion is the easiest thing in the world to undo six months later, in good faith, by someone adding a loop and reaching for the habit. So all three table views carry the reasoning at the top of the file:

{{--
    Do not put wire:key on a <flux:*> tag. Livewire's SupportCompiledWireKeys
    precompiler injects a <?php ?> block immediately before the attribute, i.e.
    inside the tag, and the Blade component compiler then emits invalid PHP:
    "syntax error, unexpected token endif". Livewire 4 derives loop keys itself
    (config livewire.smart_wire_keys, on by default), so the manual attribute is
    redundant here. On a plain HTML element wire:key is still fine.
--}}
Enter fullscreen mode Exit fullscreen mode

Note the last line. A comment that says "never use wire:key" would be wrong and would eventually be ignored for being wrong — the dashboard still uses it on <li> elements, correctly. Scope the prohibition to the actual boundary, or people learn to distrust your comments.

The stronger guard would be a test, and for anything with a PHP seam that's what I'd write. Here the failure mode is a compile error in a template, so the check that would catch it is a rendering test with a cold view cache — which is the discipline the whole of yesterday was about, rather than a new assertion.

What "verified" means now

After yesterday, "the suite is green" isn't a claim I get to make cheaply. This release was checked on Livewire v4.4.2 from a cold Blade cache, twice, plus a random-order run: 233 passed, PHPStan level 5 with an empty baseline, Pint clean.

The cold cache matters because of the thing that burned me: compiled views under vendor/orchestra/testbench-core/laravel/storage/framework/views/ are keyed on the Blade source. Change the Livewire major without clearing them and the suite happily re-runs against templates compiled by the old precompiler. That rule now lives in both CLAUDE.md and CONTRIBUTING.md, because it isn't a one-off — it's true for every future view change too.

The takeaway

Dropping a major version feels like the aggressive option, so it tends to be the last one considered. But run the actual comparison:

  • Support both: one bug stays open on the new major, the workaround for the other is a version sniff, the provider carries reflection-flavoured guards, and every future view change has to be reasoned about twice.
  • Drop the old major: 1.0.x keeps serving Livewire 3 apps exactly as well as it does today, and the main line gets to be simple and correct.

A 1.0.x branch isn't abandonment. It's the compatibility shim, moved to where it costs nothing — outside the code you're still writing.

If you're maintaining a package sitting across a major-version boundary, the question worth asking isn't "can I support both?" It's "is there a fix I'm not making because I support both?" If yes, you've already paid more than the bump would have cost.

Top comments (0)