Skip to content

[2.x] [bug] Fix SerializableClosure as class property being unwrapped during deserialization#135

Merged
taylorotwell merged 1 commit into
laravel:2.xfrom
JoshSalway:fix/serializable-closure-as-property
Apr 3, 2026
Merged

[2.x] [bug] Fix SerializableClosure as class property being unwrapped during deserialization#135
taylorotwell merged 1 commit into
laravel:2.xfrom
JoshSalway:fix/serializable-closure-as-property

Conversation

@JoshSalway

@JoshSalway JoshSalway commented Mar 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #106
Also fixes: laravel/framework#57597, laravel/framework#50028

When a class has a SerializableClosure or UnsignedSerializableClosure instance as a property value, and that class is captured in another closure's use variables, deserialization incorrectly unwraps the property to a raw Closure by calling getClosure() on it. This causes a "Call to undefined method Closure::getClosure()" error when the property is later used as a SerializableClosure.

The root cause is in Native::__unserialize() - the objects restoration loop unconditionally calls $item['object']->getClosure() on every stored object, but SerializableClosure and UnsignedSerializableClosure instances should be preserved as-is rather than unwrapped.

This bug was inherited from opis/closure 3.x (same unconditional getClosure() call). It went unnoticed because the trigger pattern (SC as a typed class property on a serialized object) only became common with PHP 8.0+ typed properties and features like ChainedBatch.

The fix

A simple instanceof check before calling getClosure():

  • If the object is a SerializableClosure or UnsignedSerializableClosure, keep it as-is
  • Otherwise (it's a Native serializer instance), call getClosure() to unwrap to the raw Closure

Real-world issues

This bug has been reported multiple times across different repos and forums since 2021:

Report Description Workaround used This PR fixes it?
serializable-closure #106 Bus::chain closure after Bus::batch silently fails None - this PR is the fix Yes - tested on fresh Laravel 13 app
framework #57597 Same bug on Laravel 12, confirmed by multiple users None - this PR is the fix Yes - same code path as #106
framework #50028 Same bug on Laravel 10 with Redis queue driver Closed as support question Yes - same code path
framework #35572 Bus::batch inside Bus::chain serialization error Closed as not supported at the time, suggested wrapping batch in a closure Yes - the pattern was later added to the framework but the serialization bug remained
Laracasts thread "Closure in Bus::chain is not executed if a batch comes before it" Unknown Yes - same code path

Affected packages

Any class that stores a SerializableClosure as a property and is then captured in another closure's scope during serialization will hit this bug:

Package Property How it breaks Tested? Fixed by this PR?
laravel/framework CallQueuedClosure public $closure typed as SerializableClosure handle() calls $this->closure->getClosure() which fails after unwrap Yes - reproduced the handle() and displayName() patterns on a fresh Laravel 13 app Yes
laravel/framework Queueable trait public $deduplicator typed as SerializableClosure|null Deduplicator closure unwrapped when job is captured in another closure's scope Not directly - same underlying code path as CallQueuedClosure Yes
ksassnowski/venture ClosureWorkflowStep private SerializableClosure $callback Workflow step callback unwrapped during chain serialization (#75) Reproduced the pattern (private SC property on ShouldQueue class, getClosure() in handle) Yes
pxlrbt/filament-excel Column / CanModifyQuery public ?SerializableClosure typed properties Typed properties get a raw Closure assigned, violating the type declaration Reproduced the pattern (typed ?SerializableClosure property, multiple SC properties on same object) Yes

Verification

Tested on a fresh Laravel 13 app against v2.0.10 (before) and this PR (after). Every closure pattern from the Laravel 13.x queue documentation was included to check for regressions. Test code: JoshSalway/sc-135-tests.

Docs pattern tests (18 tests: 6 fixed, 12 already passing)
# Pattern Before After
01 dispatch(fn() => ...) Pass Pass
02 dispatch(fn())->name(...) Pass Pass
03 dispatch(fn())->catch(fn()) Pass Pass
04 Bus::chain([Job, Job, Job]) Pass Pass
05 Bus::chain([Job, Job, fn()]) Pass Pass
06 Bus::chain([...])->catch(fn()) Pass Pass
07 Bus::batch([...])->then()->catch()->finally() Pass Pass
08 Bus::batch([[Job, Job], [Job, Job]])->then() Pass Pass
09 Bus::chain([Job, Bus::batch([...]), Bus::batch([...])]) Pass Pass
10 Bus::chain([Job, Bus::batch([...]), fn()]) Pass* Pass
11 Bus::chain([Bus::batch([...]), fn(), Bus::batch([...])]) Pass* Pass
12 Bus::chain([Job, Bus::batch([...]), fn()])->catch(fn()) Pass* Pass
13 SC property roundtrip FAIL Pass
14 CallQueuedClosure handle() simulation FAIL Pass
15 Batch callback with SC object FAIL Pass
16 Dispatch catch with SC object FAIL Pass
17 Chain catch with SC object FAIL Pass
18 Deduplicator pattern FAIL Pass

*Pass with Bus::fake (no serialization). Fails during actual queue processing.

Package pattern and edge case tests (24 tests: 18 fixed, 6 already passing)
# Test Source Before After
01 Closure after batch in chain #106 Pass* Pass
02 SC property roundtrip (native) Core bug FAIL Pass
03 SC property roundtrip (signed) Core bug FAIL Pass
04 Unsigned SC property roundtrip Core bug FAIL Pass
05 Typed ?SC property preserved filament-excel FAIL Pass
06 Typed SC property callable filament-excel FAIL Pass
07 CallQueuedClosure handle() Framework FAIL Pass
08 CallQueuedClosure displayName() Framework Pass Pass
09 Private SC on ShouldQueue venture FAIL Pass
10 Multiple SC properties filament-excel FAIL Pass
11 Nested object with SC Edge case FAIL Pass
12 Mixed SC and regular props Edge case FAIL Pass
13 Double roundtrip Edge case Pass Pass
14 Direct __invoke Breakage Pass Pass
15 is_callable Breakage Pass Pass
16 instanceof SerializableClosure Breakage FAIL Pass
17 instanceof Closure Breakage FAIL Pass
18 Null property Edge case Pass Pass
19 SC with parameters Edge case FAIL Pass
20 SC with captured variables Edge case FAIL Pass
21 Notification after batch Real-world FAIL Pass
22 Status update after batch Real-world FAIL Pass
23 Pipeline steps Real-world FAIL Pass
24 Unsigned inside signed Mixed signing FAIL Pass

PR #129 regression tests also verified (9/9 pass).

Breakage analysis

Pattern Before After
$property->getClosure() (framework pattern) Crashes: Call to undefined method Closure::getClosure() Works
$property instanceof SerializableClosure guard false (unwrapped to raw Closure) true (preserved)
Direct invocation via __invoke / ($property)() Works Works
is_callable($property) Works Works

No code in the framework or affected packages checks instanceof \Closure on SerializableClosure typed properties, so the only behavior change (property staying as SerializableClosure instead of being unwrapped to raw Closure) has no downstream impact.

Credit

Thanks to @Dry7 for the initial investigation and fix attempt in #86, which identified the same root cause.

Test plan

@github-actions

Copy link
Copy Markdown

Thanks for submitting a PR!

Note that draft PRs are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

…apped during deserialization

When a class property holds a SerializableClosure or UnsignedSerializableClosure
instance, the deserialization in __unserialize() would call getClosure() on it,
unwrapping it to a raw Closure. This caused errors like "Call to undefined
method Closure::getClosure()" when the property was later used as a
SerializableClosure.

The fix checks whether the stored object is a SerializableClosure or
UnsignedSerializableClosure before deciding whether to unwrap it.

Fixes #106

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@taylorotwell taylorotwell merged commit ccdfd65 into laravel:2.x Apr 3, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Call to undefined method Closure::getClosure() when chaining closures after batches Closure in job chain is not called if preceded by job batch

2 participants