Skip to main content
Make sure jobs dispatched before a package update can still be processed after it. What the queue stores is not the code to run but the job class name and its state. Besides unit tests for the new code, you need tests that restore state saved by the old code with the new code.
This page targets Laravel 13 and checks the implementation of Framework v13.42.0. It assumes queue basics and package testing. The compatibility design below is a package-side policy. Laravel has no feature that automatically migrates your own payload formats.

Code changes between storing and running

For object jobs, Queue::createObjectPayload() runs serialize(clone $job) and puts the result in the payload’s data.command. When encryption is enabled, the same serialization happens before it. On the worker side, CallQueuedHandler::getCommand() runs unserialize() on the stored data and passes the restored job to the Dispatcher. A regular handle() runs with the worker’s code and service container as they are at that moment. If you do not restart long-running workers, the old code keeps running after the update. With rolling deployments, consider not only “old dispatcher with new worker” but also “new dispatcher with old worker”. Do not judge renaming or removing a class only by whether users call that class directly. Jobs that are waiting, delayed or awaiting retry, and the retention period of failed jobs, are also part of compatibility.

What SerializesModels stores

Illuminate\Foundation\Queue\Queueable uses SerializesModels internally. Besides replacing models, it provides __serialize(), which lists the job’s own properties, and __unserialize(), which puts the values back.

Values equal to the declared default are not stored

__serialize() excludes static and uninitialized properties, and it also skips storing a property when its current value is strictly equal to the property’s declared default.
If this property is still 'csv', format is not in the stored array. If a later release changes the declared default to 'json', 'json' may also apply to old jobs. This is not a cosmetic default change: it changes how old data is read.

The constructor is not called again on restore

__unserialize() assigns values to properties that exist on the current class and have a key in the stored array. It does not run any filling or validation of input through the constructor.
The 'csv' in this example is the default of a constructor argument. It is not the property’s own declared default, so it does not fill in the value when you restore an old payload without format. If an added typed property stays uninitialized, accessing it later fails. Also, a public property’s key is its name, a protected property’s key has a prefix for its visibility, and a private property’s key also includes the class name. Verify a change from public to private with old payloads too.
SerializesModels lists the job’s properties. Do not assume the same conversion runs recursively inside arbitrary DTOs or on models deep inside arrays. When you pass objects other than models, their classes and storage formats are also part of compatibility.

Separate identifiers from execution services

Store the identifiers and options needed for execution in the job, and inject execution services such as API clients and exporters into handle(). If you pass a service to the constructor and keep it, its internal state may become part of what is stored. The next example adds a format choice and a payload version to a job with the same name that held only reportId in the old version. ReportExporter is a contract the package provides and binds in the container, with export(string $reportId, string $format): void.
This design separates the meaning of missing values from the values newly dispatched.
  • If an old payload has no format, the declared default 'csv' is used.
  • If an old payload has no payloadVersion, it is treated as the declared default 1.
  • For newly created jobs, the constructor sets payloadVersion to 2. Because it differs from the declared default, this value is stored.
  • If a new job’s format is 'json', that value is stored too. 'csv' is skipped, so keep the declared default 'csv' from now on.
If you later want to change only the default format of new jobs, you can change the constructor argument’s default and the assignment, and consider them separately from the property default used for old data. Manage the payload version not as the package’s release number but as the generation of the storage format and its interpretation.
Adding a version number alone does not give you compatibility. The new version in this example can also process the old format, but there is no guarantee that old workers understand the new format or payloadVersion. You also need the order of dispatcher and worker updates described below.

Distinguish passing models from snapshots

When you store a model directly in a property, SerializesAndRestoresModelIdentifiers replaces it with a ModelIdentifier holding the class, ID, connection name, names of loaded relations and so on. When restoring a single model, it re-retrieves it from the database with firstOrFail() and loads the target relations. This is not a mechanism that freezes attributes or related data as of dispatch time.
  • If the record is updated before execution, the restored model reflects the updated content.
  • Constraints applied to relations beforehand are not stored, so reapply the conditions you need at execution time.
  • Exclude unneeded relations from storage with withoutRelations() or #[WithoutRelations].
  • If the model is deleted, the job can fail during the restore step, before the job’s handle().
If it is acceptable to discard jobs whose model was deleted, you can consider the official #[DeleteWhenMissingModels]. However, the package should not make discarding the default on its own; expose the failure policy to users. If you pass only an ID and look it up in handle(), you control the failure or skipping of that lookup yourself. If you need the content as of dispatch time, store the values you need explicitly, or record something like an immutable report request in the database and pass its ID. Distinguish simply passing a model from storing a snapshot of business data.

Dispatching after commit is a separate problem

When you process records created inside a transaction, make sure workers do not start reading them before the commit.
afterCommit() waits to dispatch until the open parent transaction commits, and discards the dispatch on rollback. It does not solve class name and property compatibility or duplicate execution of external processing.

Separate fakes from old payload tests

A regular Queue::fake() records dispatches. A passing assertPushed() alone does not confirm storing and restoring, or that the new version processes old payloads. You can verify a round trip with the current class like this.
serializeAndRestore() runs serialize() and unserialize() inside the fake. However, it does not verify dispatching to a real backend, execution by a worker, or generation by the old code.

Keep fixtures generated by the old version

While the old GenerateReport has only reportId, generate a fixed job that contains no real data. The following is preparation code you run once in the old version’s test environment. Create tests/fixtures/ beforehand.
Put the fixture under version control and load it from the new version’s tests. Regenerating it with the new version on every test run would no longer verify backward compatibility, so keep it fixed.
The TestCase in this example is the base class you set up in the package testing environment. Only pass fixtures you manage yourself to unserialize(), never external input. Also run the restored old job’s handle() with a test exporter and verify its output and stored results. When you pass models, separately verify updates, deletion, connections and re-retrieval of relations, and, on the queue connection you actually use, dispatching after commit and retries.

Update the reading side before dispatching the new format

1

Prepare a release that can read the old format

First ship code that keeps the old class name and the interpretation of old values and can also read the new format. Use fixtures to check not only restoring but also processing results.
2

Confirm that no old workers remain

Include replacing workers in the consuming app’s deployment procedure. With a regular queue:work, php artisan queue:restart asks workers to exit after their current job, and you confirm that the process manager starts the new version. The cache is used for the restart signal.
3

Enable dispatching the new format

Once the reading side supports it, switch to dispatching the new format, for example with a feature flag. If shipping the same code also starts generating the new format, you need a procedure that keeps it away from old workers, such as pausing dispatch or separating old and new queues with matching workers.
4

Maintain support through the retry and rollback period

Also check retries of failed jobs and jobs dispatched later within chains. If you roll back to old code after storing the new format, verify that the old code can read it. Do not remove support for old classes or old formats just because the queue was empty once.
queue:restart is not a command that rewrites stored payloads. In the package’s upgrade notes, state the supported formats, the required worker replacement and the conditions for retiring the old format. Record the same change in version compatibility management and changelog practices.

Primary sources

Last modified on November 12, 2026