COLOPL Tech Blog

コロプラのエンジニアブログです

PHPStanのカスタムルールを使って、Laravelアップデート時のシリアライズ事故を防ぐ仕組みを作ってみた

こんにちは。バックエンドエンジニアの薮 (@tyabu12) です。

Laravel 11 のセキュリティEOLが3月に迫ってきましたね。

今回は Laravel 12 への更新時に遭遇したシリアライズの問題と、シリアライズ事故を事前検知する仕組みをご紹介できればと思います。

依存パッケージ更新に伴うシリアライズの問題は、コードレビューやデバッグでの発見が困難で未然に検知するのが難しいです。

本記事では、こうしたシリアライズ事故を未然に防ぐために、PHPStan のカスタムルールを活用する方法を紹介します。

2026/03/03追記・修正

Xにて、にゃんだーすわん (@tadsan) さんから PHPStan の型判定に instanceof を使うべきではないというご指摘をいただきました。 (※本記事でのご紹介については、ご本人より掲載のご了承をいただいております)

instanceof のチェックでは型を正しく判定できるとは限らないそうで1instanceof を使わずヘルパーメソッドを使う記法 を教えていただき、本記事も合わせて isAllowedType() の処理を修正いたしました。

ご指摘ありがとうございました!

背景:Laravel 11 から 12 への更新作業でシリアライズのエラーに遭遇

私たちのプロジェクトでは、Laravel 11 から 12 へのアップデート作業中に、 開発環境でシリアライズ (serialize() / unserialize()) を使用している箇所で予期しないエラーが発生することを発見しました。

Model に新規で追加されたプロパティが動的プロパティとして解釈され、Laravel 11 で serialize() した Model を unserialize() すると、以下のような ErrorException が発生したのです。

ErrorException: Creation of dynamic property App\Models\HogeModel::$previous is deprecated
ErrorException: Creation of dynamic property App\Models\HogeModel::$relationAutoloadCallback is deprecated
ErrorException: Creation of dynamic property App\Models\HogeModel::$relationAutoloadContext is deprecated

シリアライズというと馴染みがない方が多いかもしれません。Laravel で内部的にシリアライズが使われている代表的な処理のひとつに Job があります。

Job を dispatch で送信して Queue に保存される際の処理を見てみましょう。

<?php

// このジョブは、デフォルト接続のデフォルトキューに送信される
ProcessPodcast::dispatch();

dispatch された Job は Queue::createPayload() で string に変換されます。 createPayload() の変換処理を追っていくと Job がオブジェクトの場合は Queue::createObjectPayload()serialize() されることが分かります。

<?php

namespace Illuminate\Queue;

abstract class Queue
{
    // ...

    /**
     * Create a payload for an object-based queue handler.
     *
     * @param  object  $job
     * @param  string  $queue
     * @return array
     */
    protected function createObjectPayload($job, $queue)
    {
        $payload = $this->withCreatePayloadHooks($queue, [
            'uuid' => (string) Str::uuid(),
            'displayName' => $this->getDisplayName($job),
            'job' => 'Illuminate\Queue\CallQueuedHandler@call',
            'maxTries' => $this->getJobTries($job),
            'maxExceptions' => $job->maxExceptions ?? null,
            'failOnTimeout' => $job->failOnTimeout ?? false,
            'backoff' => $this->getJobBackoff($job),
            'timeout' => $job->timeout ?? null,
            'retryUntil' => $this->getJobExpiration($job),
            'data' => [
                'commandName' => $job,
                'command' => $job,
            ],
            'createdAt' => Carbon::now()->getTimestamp(),
        ]);

        try {
            $command = $this->jobShouldBeEncrypted($job) && $this->container->bound(Encrypter::class)
                ? $this->container[Encrypter::class]->encrypt(serialize(clone $job))
                : serialize(clone $job);
        } catch (Throwable $e) {
            throw new RuntimeException(
                sprintf('Failed to serialize job of type [%s]: %s', get_class($job), $e->getMessage()),
                0,
                $e
            );
        }

        return array_merge($payload, [
            'data' => array_merge($payload['data'], [
                'commandName' => get_class($job),
                'command' => $command,
            ]),
        ]);
    }

今度は Job 実行時の処理を見ていきましょう。

createObjectPayload()'job' に指定しているIlluminate\Queue\CallQueuedHandler@call を見てみます。

<?php
namespace Illuminate\Queue;

class CallQueuedHandler
{
    // ...

    /**
     * Get the command from the given payload.
     *
     * @param  array  $data
     * @return mixed
     *
     * @throws \RuntimeException
     */
    protected function getCommand(array $data)
    {
        if (str_starts_with($data['command'], 'O:')) {
            return unserialize($data['command']);
        }

        if ($this->container->bound(Encrypter::class)) {
            return unserialize($this->container[Encrypter::class]->decrypt($data['command']));
        }

        throw new RuntimeException('Unable to extract job payload.');
    }
}

unserialize() によりシリアライズを解除してオブジェクトを復元していることが分かります。 特に意識していないだけで、自然とシリアライズ処理を使っていることが分かりましたね!

例えば Laravel 更新対応をローリングアップデートでデプロイする際、新旧の Laravel アプリケーションでシリアライズ済みのデータを unserialize() でシリアライズ解除する可能性が出てきます。

もしシリアライズ解除ができない場合は ErrorException を吐いてしまい、ジョブの実行が止まってしまいます2

ローリングアップデートで曲者なのが、更新後の Laravel が更新前の Job をシリアライズ解除することもあれば、更新前の Laravel が更新後の Job をシリアライズ解除する可能性もあります。

sequenceDiagram
    box ローリングアップデート中の問題(逆もあり)
    participant L12 as Laravel 12
    participant Q as Queue
    participant L11 as Laravel 11
    end
    
    L12->>Q: serialize()
    Q->>L11: Job取得
    L11->>L11: unserialize()
    Note over L11: ErrorException<br/>発生の可能性

この現象はデプロイ前後という限られたタイミングでのみ発生し、コードレビューやQAでも検知は困難です。

そこで、この問題を PHPStan のカスタムルールを使って静的解析をすることで、事前に自動検知する仕組みを導入してみました。

PHPStan のカスタムルールによる検出

PHPStan では通常の静的解析に加えて独自でカスタムルールを定義できます。

カスタムルールの詳しい作成方法については、下記の記事をご参照ください。

blog.colopl.dev

今回実装するカスタムルールは、Job クラス (ShouldQueueimplements したクラス) のプロパティの型が、以下のいずれかの条件を満たすかチェックします。

  • null
  • スカラー型 (bool, int, float, string)
  • array (array のネスト含む)
  • enum
  • (SerializesModels trait を使用している場合は) Model

これにより、Closure や CarbonInterface などをはじめとするオブジェクトなど、シリアライズ時に危険性がある型使った場合に、静的解析で機械的に検知できます。

実装のポイント

カスタムルールの実装にあたっていくつかポイントがあったため、そちらをご紹介します。

先に 最終的なソースコードを見たい場合はこちら

1. InClassNode の活用

PHPではクラスのプロパティの定義方法は2種類あります。 通常のプロパティ宣言と、コンストラクタでのプロパティ昇格です。 それぞれAST上では異なるノードとして表現されます。

ノード
通常のプロパティ Node\PropertyItem
プロパティ昇格 Node\Param (isPromoted() = true)

異なるノードそれぞれに対するカスタムルールを書くこともできます。 ただ、今回作成するカスタムルールではプロパティの型さえ分かれば良く、どちらの方法で定義されているかを区別する必要はありません。

こういう時は InClassNodeClassReflection を組み合わせることで、ソースコード上の記述位置を問わず、クラスが持つプロパティを一貫してチェックするルールを記述できます。

<?php

class JobOnlyPrimitiveOrQueueablePropertiesRule implements Rule
{
    /**
     * @inheritDoc
     */
    public function getNodeType(): string
    {
        return InClassNode::class;
    }

    /**
     * @inheritDoc
     */
    public function processNode(Node $node, Scope $scope): array
    {
        // 対象のクラスのリフレクションが取得できる!
        // getNodeType() が Stmt\Class_ だとまだクラスの解析が終わっていないので、ここで $scope->getClassReflection() は取得できずにnullになってしまう
        $classReflection = $scope->getClassReflection();

        // ShouldQueue インターフェースを実装しているかチェック
        if ($classReflection === null || !$classReflection->implementsInterface(ShouldQueue::class)) {
            return [];
        }

        // 通常のプロパティも、コンストラクタのプロパティ昇格も一括で取得できる
        $properties = $classReflection->getNativeReflection()->getProperties();

        // TODO: $properties のチェック処理
    }
}

2. 再帰的な型判定

プロパティの型は、配列のネストや Union 型 (例: int|Carbon) が含まれる場合があります。そのため、以下のように再帰的にチェックします。

※2026/03/03 ご指摘をいただいたので追記・修正しました

<?php

function isAllowedType(Type $type): bool
{
    // 1. まず Null を型から取り除く(Nullable をシンプルに扱うため)
    $type = TypeCombinator::removeNull($type);

    // 元が null のみだった場合は NeverType(BottomType) になるので許可
    if ($type instanceof NeverType) {
        return true;
    }

    // 2. 配列(またはそのUnion)の判定
    // removeNull したおかげで、`array|null` も安全にここでキャッチできます
    if ($type->isArray()->yes()) {
        return $this->isAllowedType($type->getIterableValueType());
    }

    // 3. スカラー、またはEnumの判定
    // `int|string` のような同種のUnionであっても、yes() が true になります
    if ($type->isScalar()->yes() || $type->isEnum()->yes()) {
        return true;
    }

    // 4. 異種の複合型(例: `int|array` や `string|QueueableEntity`)のフォールバック処理
    // instanceof UnionType を避け、PHPStan公式の安全な分解ユーティリティを使います
    $types = TypeUtils::flattenTypes($type);
    if (count($types) > 1) {
        foreach ($types as $innerType) {
            if (!$this->isAllowedType($innerType)) {
                return false;
            }
        }
        return true;
    }

    return false;
}

再帰処理により、array<int, string>array<Model> のようなネストされた型も正しく検証できます。

3. SerializesModels trait の判定

Laravel の SerializesModels trait を使用している場合は、Eloquent Model のシリアライズが安全に行われます。

SerializesModels trait は、安全に Model クラスをシリアライズする手法です。 モデルの全プロパティをシリアライズするのではなく、モデルのクラス名やIDなどの識別情報のみをシリアライズし、デシリアライズ時はDBから再度モデルを取得します。 これによりシリアライズに起因する問題や、Job のキューイング中に Model が変更されて古い状態のまま処理されてしまうリスクを軽減できます。ただし、キュー投入から実行までの間に Model 自体が削除されたり、前提としている状態から大きく変化した場合の扱いについては、引き続き注意が必要です。

SerializesModels trait を使用しているかは $classReflection から容易に判定できます。

<?php

$classReflection = $scope->getClassReflection();
$allowQueueable = $classReflection->hasTraitUse(SerializesModels::class);

4. 外部パッケージの除外

Illuminate\ の以下のプロパティは静的解析の対象から除外します。

これによって Illuminate\Queue\InteractsWithQueueIlluminate\Bus\Queueable などの trait によって追加されるプロパティは検証対象から除外します3

<?php

$declaringClassName = $property->getDeclaringClass()->getName();
// 依存パッケージ側はチェックしない
if (str_starts_with($declaringClassName, 'Illuminate\\')) {
    continue;
}

最終的なソースコード

以上を踏まえて、最終的なソースコードはこのような形になります。

<?php

declare(strict_types=1);

namespace CustomPHPStan\Rules\Jobs;

use Illuminate\Contracts\Queue\QueueableCollection;
use Illuminate\Contracts\Queue\QueueableEntity;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;

/**
 * @implements Rule<InClassNode>
 */
class JobOnlyPrimitiveOrQueueablePropertiesRule implements Rule
{
    /** @var list<ObjectType>|null */
    private ?array $queueableObjectTypes = null;

    /**
     * @inheritDoc
     */
    public function getNodeType(): string
    {
        // Stmt\Class_ だとまだクラスの解析が終わっていないので、$scope->getClassReflection() が取得できずにnullになってしまう
        return InClassNode::class;
    }

    /**
     * @inheritDoc
     */
    public function processNode(Node $node, Scope $scope): array
    {
        $classReflection = $scope->getClassReflection();

        // ShouldQueue インターフェースを実装しているかチェック
        if ($classReflection === null || !$classReflection->implementsInterface(ShouldQueue::class)) {
            return [];
        }

        $allowQueueable = $classReflection->hasTraitUse(SerializesModels::class);

        $errors = [];
        $properties = $classReflection->getNativeReflection()->getProperties();

        foreach ($properties as $property) {
            // staticプロパティはシリアライズ対象外なのでスキップ
            if ($property->isStatic()) {
                continue;
            }

            $declaringClassName = $property->getDeclaringClass()->getName();
            // 依存パッケージ側はチェックしない
            if (str_starts_with($declaringClassName, 'Illuminate\\')) {
                continue;
            }

            $type = $property->getType();
            if ($type === null) {
                $errors[] = RuleErrorBuilder::message(
                    "Property \${$property->getName()} in job class should have a type declaration.",
                )->identifier('job.propertyMissingType')->build();
                continue;
            }

            // プロパティの型指定チェック
            $phpstanType = $classReflection->getNativeProperty($property->getName())->getReadableType();
            if (!$this->isAllowedType($phpstanType, $allowQueueable)) {
                if ($allowQueueable) {
                    $errors[] = RuleErrorBuilder::message(
                        "Property \${$property->getName()} in job class should be a primitive type, QueueableCollection, or QueueableEntity. Found: {$phpstanType->describe(VerbosityLevel::typeOnly())}",
                    )->identifier('job.propertyType')->build();
                } elseif ($this->isQueueableType($phpstanType)) {
                    $errors[] = RuleErrorBuilder::message(
                        "Property \${$property->getName()} in job class should be a primitive type or use SerializesModels trait. Found: {$phpstanType->describe(VerbosityLevel::typeOnly())}",
                    )->identifier('job.propertyType')->build();
                } else {
                    $errors[] = RuleErrorBuilder::message(
                        "Property \${$property->getName()} in job class should be a primitive type. Found: {$phpstanType->describe(VerbosityLevel::typeOnly())}",
                    )->identifier('job.propertyType')->build();
                }
            }
        }

        return $errors;
    }

    /**
     * $type が許可された型かどうかを判定
     *
     * @param bool $allowQueueable Queueableな型を許容するか
     */
    private function isAllowedType(Type $type, bool $allowQueueable): bool
    {
        // 1. まず Null を型から取り除く(Nullable をシンプルに扱うため)
        $type = TypeCombinator::removeNull($type);

        // 元が null のみだった場合は NeverType(BottomType) になるので許可
        if ($type instanceof NeverType) {
            return true;
        }

        // 2. 配列(またはそのUnion)の判定
        // removeNull したおかげで、`array|null` も安全にここでキャッチできます
        if ($type->isArray()->yes()) {
            return $this->isAllowedType($type->getIterableValueType(), $allowQueueable);
        }

        // 3. スカラー、またはEnumの判定
        // `int|string` のような同種のUnionであっても、yes() が true になります
        if ($type->isScalar()->yes() || $type->isEnum()->yes()) {
            return true;
        }

        // 4. Queueableなオブジェクトの判定
        if ($allowQueueable && $this->isQueueableType($type)) {
            return true;
        }

        // 5. 異種の複合型(例: `int|array` や `string|QueueableEntity`)のフォールバック処理
        // instanceof UnionType を避け、PHPStan公式の安全な分解ユーティリティを使います
        $types = TypeUtils::flattenTypes($type);
        if (count($types) > 1) {
            foreach ($types as $innerType) {
                if (!$this->isAllowedType($innerType, $allowQueueable)) {
                    return false;
                }
            }
            return true;
        }

        return false;
    }

    /**
     * Queueableな型かを判定
     */
    private function isQueueableType(Type $type): bool
    {
        if ($type->isObject()->no()) {
            return false;
        }

        if ($this->queueableObjectTypes === null) {
            $this->queueableObjectTypes = [
                new ObjectType(QueueableCollection::class),
                new ObjectType(QueueableEntity::class),
            ];
        }
        foreach ($this->queueableObjectTypes as $queueableObjectType) {
            if ($queueableObjectType->isSuperTypeOf($type)->yes()) {
                return true;
            }
        }
        return false;
    }
}

テスト

PHPStan\Testing\RuleTestCase クラスを継承して、カスタムルールに対するテストコードを書くことができます。

<?php

declare(strict_types=1);

namespace Tests\CustomPHPStan\Rules;

use CustomPHPStan\Rules\Jobs\JobOnlyPrimitiveOrQueueablePropertiesRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
 * @extends RuleTestCase<JobOnlyPrimitiveOrQueueablePropertiesRule>
 */
class JobOnlyPrimitiveOrQueueablePropertiesRuleTest extends RuleTestCase
{
    protected function getRule(): Rule
    {
        return new JobOnlyPrimitiveOrQueueablePropertiesRule();
    }

    public function testRule(): void
    {
        $this->analyse([__DIR__ . '/data/job-only-primitive-or-queueable-properties-args.php'], [
            [
                'Property $noTypeHint in job class should have a type declaration.',
                8,
            ],
            [
                'Property $carbon in job class should be a primitive type. Found: Illuminate\Support\Carbon|null',
                8,
            ],
            // ...
            [
                'Property $model in job class should be a primitive type or use SerializesModels trait. Found: Illuminate\Database\Eloquent\Model',
                249,
            ],
        ]);
    }
}
// tests/CustomPHPStan/Rules/data/job-only-primitive-or-queueable-properties-args.php

<?php

declare(strict_types=1);

use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;

class TestJob implements \Illuminate\Contracts\Queue\ShouldQueue
{
    public $noTypeHint;

    public function __construct(
        public Carbon $carbon,
    ) {
    }
}

// ShouldQueue を implement していないならオブジェクトをプロパティに持ってもOK
class NotShouldQueue
{
    public function __construct(
        private Carbon $carbon,
    ) {
    }
}

// SerializesModels trait を使っていないモデルを持つジョブはNG
class NotSerializesModelJob implements \Illuminate\Contracts\Queue\ShouldQueue
{
    public function __construct(
        public \Illuminate\Database\Eloquent\Model $model,
    ) {
    }
}

// SerializesModels trait を使っているモデルを持つジョブはOK
class SerializesModelJob implements \Illuminate\Contracts\Queue\ShouldQueue
{
    use \Illuminate\Queue\SerializesModels;

    /**
     * @template TModel of \Illuminate\Database\Eloquent\Model
     * @param TModel $model
     * @param \Illuminate\Database\Eloquent\Collection<int, TModel> $models
     */
    public function __construct(
        public \Illuminate\Database\Eloquent\Model $model,
        public \Illuminate\Database\Eloquent\Collection $models,
    ) {
    }
}

PHPStan へのカスタムルール組み込み

このカスタムルールを組み込むことで、Pull Request の段階でCIから問題を検知できます。

# phpstan.neon
rules:
    - CustomPHPStan\Rules\JobOnlyPrimitiveOrQueueablePropertiesRule

カスタムルール適用後の実行結果

<?php

namespace App\Jobs;

use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;

class HogeJob implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;

    private function __construct(
        private readonly CarbonInterface $triggeredAt,
    ) {
    }

    public function handle(): void
    {
        // ...
 ------ --------------------------------------------------------------------
  Line   app/Jobs/HogeJob.php
 ------ --------------------------------------------------------------------
  13     Property $triggeredAt in job class should be a primitive type.
         Found: Carbon\CarbonInterface
         🪪  job.propertyType
 ------ --------------------------------------------------------------------

無事にシリアライズ時に危険性がある Job を検出できるようになりましたね!

まとめ

今回ご紹介した PHPStan カスタムルールを導入することによって、シリアライズで危険性がある実装があった際に、自動検知する仕組みが導入できました。

この仕組みを応用することで、Job 以外でも同様な検知を仕組み化できます。 例えば Redis にシリアライズ化してオブジェクトキャッシュを保存していたりする場合に、オブジェクトがあったら検知するといったことも可能です。

PHPStan のカスタムルールは最初は書き方が分からなくてとっつきにくい印象ですが、 うまく使うとチェックを仕組み化できるため、かなり便利です。

ぜひ皆さんもカスタムルールを活用してみてください!



ColoplTechについて

コロプラでは、勉強会やブログを通じてエンジニアの方々に役立つ技術や取り組みを幅広く発信していきます。
connpass および X で情報発信していますので、是非メンバー登録とフォローをよろしくお願いいたします。


  1. Why Is instanceof *Type Wrong and Getting Deprecated?
  2. Job の場合は、Model に関してはそもそも SerializesModels trait を use して、Model インスタンスはシリアライズしないようにするのが適切です。
  3. 厳密には既存のジョブに対して新規で trait を use したり外すと、 unserialize() に失敗し ErrorException が発生する可能性があります。今回ご紹介する方法では検知が困難なため、検知対象外としています。