> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Package queued jobs and upgrade compatibility

> Reads how Laravel 13 stores and restores jobs and explains class name and property compatibility, model re-retrieval, testing old payloads and the order of worker updates.

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**.

> [!NOTE]
>
> This page targets Laravel 13 and checks the implementation of Framework `v13.42.0`. It assumes [queue basics](https://kawax.biz/en/queues.md) and [package testing](https://kawax.biz/en/advanced/package-testing.md). 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.

```mermaid theme={null}
sequenceDiagram
    participant P as Dispatcher side<br>old package version
    participant Q as Queue
    participant W as Worker<br>new package version

    P->>Q: Store class name and properties
    Note over P,W: Deploy updates the code
    Q->>W: Fetch stored payload
    W->>W: Restore into the current class
    W->>W: Run the current handle()
```

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".

| What you change | Effect on stored jobs |
| - | - |
| The job's fully qualified class name | If the class cannot be loaded under the old name, the job cannot be restored correctly |
| Property names and visibility | The keys at storage time and at restore time may no longer match |
| Property types | Old values may not be assignable, so restoring may fail |
| Declared property defaults | The meaning of values whose storage was skipped may change |
| `handle()` or the services it depends on | Even if restoring works, the result of processing old data may change |

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**.

```php theme={null}
public string $format = 'csv';
```

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.

```php theme={null}
public function __construct(
    public string $format = 'csv',
) {}
```

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.

> [!WARNING]
>
> `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`.

```php theme={null}
<?php

namespace Acme\Reports\Jobs;

use Acme\Reports\Contracts\ReportExporter;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use UnexpectedValueException;

class GenerateReport implements ShouldQueue
{
    use Queueable;

    public string $format = 'csv';

    public int $payloadVersion = 1;

    public function __construct(
        public string $reportId,
        string $format = 'csv',
    ) {
        $this->format = $format;
        $this->payloadVersion = 2;
    }

    public function handle(ReportExporter $exporter): void
    {
        if (! in_array($this->payloadVersion, [1, 2], true)) {
            throw new UnexpectedValueException('Unsupported report payload version.');
        }

        $exporter->export($this->reportId, $this->format);
    }
}
```

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**.

> [!TIP]
>
> 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.

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;
use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($reportId) {
    // Create or update the target record in this transaction.

    GenerateReport::dispatch($reportId)->afterCommit();
});
```

`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.

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;
use Illuminate\Support\Facades\Queue;

Queue::fake()->serializeAndRestore();

GenerateReport::dispatch('report-001', 'json');

Queue::assertPushed(GenerateReport::class, function (GenerateReport $job) {
    return $job->reportId === 'report-001'
        && $job->format === 'json'
        && $job->payloadVersion === 2;
});
```

`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.

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;

file_put_contents(
    __DIR__.'/fixtures/generate-report-v1.serialized',
    serialize(new GenerateReport('report-legacy')),
);
```

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.

```php theme={null}
<?php

namespace Acme\Reports\Tests;

use Acme\Reports\Jobs\GenerateReport;

class JobCompatibilityTest extends TestCase
{
    public function test_new_payload_keeps_explicit_values(): void
    {
        $job = unserialize(serialize(new GenerateReport('report-001', 'json')));

        $this->assertInstanceOf(GenerateReport::class, $job);
        $this->assertSame('report-001', $job->reportId);
        $this->assertSame('json', $job->format);
        $this->assertSame(2, $job->payloadVersion);
    }

    public function test_default_format_is_omitted_but_new_version_is_saved(): void
    {
        $values = (new GenerateReport('report-001'))->__serialize();

        $this->assertArrayNotHasKey('format', $values);
        $this->assertSame(2, $values['payloadVersion']);
    }

    public function test_old_payload_uses_legacy_defaults(): void
    {
        $job = unserialize(file_get_contents(
            __DIR__.'/fixtures/generate-report-v1.serialized',
        ));

        $this->assertInstanceOf(GenerateReport::class, $job);
        $this->assertSame('report-legacy', $job->reportId);
        $this->assertSame('csv', $job->format);
        $this->assertSame(1, $job->payloadVersion);
    }
}
```

The `TestCase` in this example is the base class you set up in the [package testing environment](https://kawax.biz/en/advanced/package-testing.md). 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](https://kawax.biz/en/advanced/package-versioning.md) and [changelog practices](https://kawax.biz/en/advanced/package-changelog.md).

## Primary sources

* [Laravel docs: Class structure and dependency injection](https://github.com/laravel/docs/blob/13.x/queues.md#class-structure)
* [Laravel docs: Jobs and database transactions](https://github.com/laravel/docs/blob/13.x/queues.md#jobs-and-database-transactions)
* [Laravel docs: Queue workers and deployment](https://github.com/laravel/docs/blob/13.x/queues.md#queue-workers-and-deployment)
* [Laravel docs: Ignoring missing models](https://github.com/laravel/docs/blob/13.x/queues.md#ignoring-missing-models)
* [Queue: Creating object payloads](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/Queue.php)
* [Queueable: Composition of the job trait](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Foundation/Queue/Queueable.php)
* [SerializesModels: Storing and restoring properties](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/SerializesModels.php)
* [SerializesAndRestoresModelIdentifiers: Replacing and re-retrieving models](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/SerializesAndRestoresModelIdentifiers.php)
* [CallQueuedHandler: Restoring and running](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/CallQueuedHandler.php)
* [QueueFake: Storing and restoring inside the fake](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Support/Testing/Fakes/QueueFake.php)
* [Laravel framework tests: Model serialization](https://github.com/laravel/framework/blob/v13.42.0/tests/Integration/Queue/ModelSerializationTest.php)


## Related topics

- [Testing Laravel packages with Orchestra Testbench](https://kawax.biz/en/advanced/package-testing.md)
- [Advanced Topics](https://kawax.biz/en/advanced/index.md)
- [Package Version Compatibility Management](https://kawax.biz/en/advanced/package-versioning.md)
- [Package morph maps and stored data compatibility](https://kawax.biz/en/advanced/package-morph-maps.md)
- [Registering and maintaining scheduled tasks in packages](https://kawax.biz/en/advanced/package-scheduling.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.