Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Serializable Closure Laravel Package

laravel/serializable-closure

Laravel Serializable Closure lets you safely serialize closures in PHP 7.4+, so they can be stored or passed between processes and later restored as callable closures. Set a secret key for secure serialization; a maintained fork of opis/closure 3.x.

View on GitHub
Deep Wiki
Context7

Getting Started

Reach for this package any time a Closure has to cross a process boundary: a queued job, a cache entry, a database row holding a "callback", a webhook retry payload, or a deferred pipeline stage. PHP's serialize() refuses closures outright, so laravel/serializable-closure is the thing that makes serialize(new SerializableClosure($fn)) legal — it reflects on the closure, extracts its source code (file + line + column), captures use variables and the bound object, and evals the reconstructed code on the other side.

composer require laravel/serializable-closure

Requires PHP 7.4+. Pin the 2.x line if you're on PHP 8.4/8.5 or Laravel 12/13 — 2.0.9+ handles virtual properties, property hooks, and nested-closure fixes that 2.0.0–2.0.8 regressed.

The minimal round trip:

use Laravel\SerializableClosure\SerializableClosure;

SerializableClosure::setSecretKey(config('app.key')); // once, at boot

$payload = serialize(new SerializableClosure(fn () => 'james'));

// ...later, possibly in another process...
$closure = unserialize($payload)->getClosure();

echo $closure(); // james

getClosure() is the step people forget — the unserialized value is a SerializableClosure wrapper (or a Native/Signed serializer instance), not a callable.

If you're in a Laravel app, you mostly won't call this directly. The framework already uses it for dispatch(fn () => ...), Bus::chain([...]), ->catch() / ->finally() callbacks, and anything else that puts a closure in a queue payload. First use case to try: replace a throwaway one-line job class with dispatch(fn () => $invoice->markPaid()) and then read Illuminate\Queue\CallQueuedClosure to see exactly how the framework wraps it (unsigned, nested inside another SerializableClosure).

Where to look first in this repo: src/SerializableClosure.php (public API + global serializer state), src/Serializers/Native.php and Signed.php (the two serialization strategies), src/Support/ReflectionClosure.php (the source-extraction guts — this is where 90% of the bugs in the changelog live), and CHANGELOG before every bump, because the version-specific edge cases are numerous.

Implementation Patterns

Wrapper value objects for stored callbacks. If you persist a closure (custom rule, tenant hook, pipeline step), don't serialize the closure alone — define a small DTO that owns it:

final class PipelineStep implements Serializable
{
    private string $serialized;

    public function __construct(public string $name, Closure $handler)
    {
        $this->serialized = serialize(new SerializableClosure($handler));
    }

    public function handler(): Closure
    {
        return unserialize($this->serialized)->getClosure();
    }
}

This keeps the SerializableClosure boundary explicit and gives you one place to add signing, validation, or versioning.

Signed vs. unsigned, deliberately. SerializableClosure::setSecretKey() switches the global serializer to HMAC-signed mode; without a key you get the plain Native serializer. Signed is correct when the payload crosses a trust boundary (cache, DB, HTTP, anything a compromised store could rewrite) because HMAC verification blocks code injection. Unsigned is correct for internal queue payloads where the payload is already inside your trust perimeter and where a APP_KEY rotation would otherwise break every in-flight job — which is exactly why Laravel queues use the unsigned variant. Don't set a secret key "just in case" and then be surprised when rotating APP_KEY strands the queue.

Queueing closures with context. Capture scalars and IDs, not models:

dispatch(function () use ($orderId) {
    Order::findOrFail($orderId)->reconcile();
})->delay(now()->addMinutes(5));

Captured objects are serialized into the closure — full Eloquent model graphs bloat the payload, snapshot stale attributes, and skip the freshness guarantees SerializesModels gives you on regular jobs. The same applies to Bus::chain():

Bus::chain([
    fn () => $report->generate(),
    fn () => $report->notifyOwner(),
])->catch(fn (Throwable $e) => Log::error($e->getMessage()))->dispatch();

Round-trip assertions in tests. The cheapest way to catch "this closure can't survive a queue" is a helper:

function roundTrip(Closure $fn): Closure
{
    return unserialize(serialize(new SerializableClosure($fn)))->getClosure();
}

$this->assertSame(42, roundTrip(fn () => 42)());

Run it against every closure you hand to dispatch() — it fails fast in CI instead of at 2 a.m. in a worker.

Custom signer / serializer. Both are small contracts (Laravel\SerializableClosure\Contracts\Signer and Contracts\Serializer). Implement Signer if you need KMS-backed HMAC or key-per-tenant selection; then SerializableClosure::setSecretKey($key, new YourSigner(...)) or setSigner(). Implement Serializer only if you need to change the wire format itself — rare.

Boot-time configuration only. setSecretKey() mutates static state on SerializableClosure. Call it once from a service provider's boot(), never from a controller or middleware, or you'll get cross-request leakage under Octane and confusing signature failures in workers.

Gotchas and Tips

REPL and eval are unsupported. Closures created in Tinker, Pest's inline eval, eval(), or by a template engine have no real file/line, so ReflectionClosure can't find their source. Test closure serialization in PHPUnit, not Tinker.

One closure per line. Two identical-signature closures on the same source line are indistinguishable after extraction. Split them onto separate lines — this is a documented caveat, not a bug you can work around.

File + line is the identity of a closure. The library re-reads the defining file at unserialize time and slices out the code by line/column. Consequences:

  • Refactor a file that defines a queued closure while jobs referencing it are still in flight → the offsets shift and the worker extracts the wrong code, or throws.
  • Deleting/renaming that file breaks every queued payload that points at it.
  • Mitigation: drain the queue before deploying, keep closure-defining files append-only, or centralize closures in dedicated rarely-touched files (e.g. app/Jobs/Closures/). Never generate closures from dynamically-written files unless you also control deploy timing.

Captured state is frozen, not live. $this on a bound closure gets serialized and rehydrated as a copy — mutations after dispatch are invisible. use by value likewise; use by reference is not meaningfully preserved. Static variables inside the closure start fresh. Resources (PDO, streams, sockets, CurlHandle) are unserializable and will blow up the whole payload.

PHP version parity. The extracted source is re-evaluated, so producer and consumer must agree on syntax: PHP 8.5 constant-expression closures, PHP 8.4 property hooks and virtual properties, enums, readonly properties, and first-class callables all have version-specific handling (see 1.0.4, 1.3.4, 2.0.3, 2.0.13). Mixed-version fleets (rolling deploys, separate queue workers on an older image) are the common failure mode.

eval must be enabled. The reconstruction path uses eval; disable_functions=eval-style hardening or Suhosin will break every unserialize with an opaque error.

Signature errors map to specific exceptions. InvalidSignatureException = wrong key or tampered payload. MissingSecretKeyException (2.0.16+) = you unserialized a signed payload without configuring a signer first. When debugging a "works locally, fails in prod" queue, dump the raw payload and check whether the producer signed and the consumer didn't.

Never unserialize untrusted closures. An unsigned serialized closure is arbitrary remote code execution by design. If user-supplied logic needs to be stored, do not use this package — use a constrained DSL or an allowlist of named handlers. Also pass ['allowed_classes' => [...]] to unserialize() when the payload source is anything less than fully internal.

Static serializer state leaks in long-running processes. Under Octane or a worker that reuses the process, anything that calls setSecretKey() / setSerializer() mid-request changes behavior for everything after it.

Read the changelog before bumping. Several 2.x patches fix regressions that only show up in real apps — the v2.0.9 nested-closure Bus::chain breakage fixed in 2.0.11, SerializableClosure unwrapping as a class property (2.0.11), nested closures in array properties (2.0.15), anonymous migration closures (2.0.12). Pin the version, run your round-trip tests, and check the changelog when a queued closure starts failing after a composer update.

Debugging trick: when extraction goes wrong, drop to the internals — (new Laravel\SerializableClosure\Support\ReflectionClosure($fn))->getCode() returns the exact source string the library will eval. Comparing that against what you expected instantly tells you whether it's a file/line problem, a namespace resolution bug, or captured-variable loss.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
bernskioldmedia/laravel-matomo
bernskioldmedia/laravel-harvest
bernskiold/laravel-data-scrubber
bernskiold/laravel-currency-converter
bernskiold/laravel-campaign-monitor
bernskioldmedia/laravel-autotranslate
siberfx/telegram-bot
roy-sandip/laravel-view-extended
gregsphotography/site-builder-core
roy-sandip/laravel-view
anodyne/blade-fluentui-icons
webx-ui/module-catalog-collections
webx-ui/module-catalog-feeds
makidizajnerica/laravel-dto
matviib/scheduler
webx-ui/templates
anodyne/blade-tabler-icons
bref/laravel-websockets
anodyne/blade-hugeicons
1994/flatpack-statamic