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

Eloquent Serialize Laravel Package

anourvalar/eloquent-serialize

Serialize and restore Eloquent query builders with ease. Capture chained queries including eager loading, where clauses, and limits, then unserialize later to run them. Supports Laravel 6 through 12.

View on GitHub
Deep Wiki
Context7

Getting Started

composer require anourvalar/eloquent-serialize

There is nothing else. No config to publish, no provider to register, no migration, no env var. Package discovery wires up the EloquentSerialize facade and you will only ever call two methods on it: serialize() and unserialize().

Before writing any production code, do the round trip in tinker on a query you actually ship, and look at both ends:

$payload = \EloquentSerialize::serialize(
    \App\Models\Order::query()
        ->where('status', 'paid')
        ->whereIn('region', ['EMEA', 'APAC'])
        ->with('customer')
        ->orderByDesc('created_at')
        ->limit(50)
);

dump($payload);                                    // what actually gets stored
$builder = \EloquentSerialize::unserialize($payload);
dump($builder->toSql(), $builder->getBindings());  // what comes back

Two things to inspect. First, the leading bytes of $payload — a: or O: means PHP serialize(), { means JSON. That single character decides your security posture and how much type fidelity you get back (see Gotchas). Second, whether toSql(), getBindings() and getEagerLoads() on the restored builder match the original. If they don't, stop and write the test below before going any further.

The README is three examples, not documentation. The real spec is vendor/anourvalar/eloquent-serialize/src/ — it is small, and reading the classes behind the facade takes ten minutes and tells you exactly which builder state survives and which is silently dropped. That is the highest-value ten minutes you will spend on this package, because there is no limitations matrix anywhere else.

Then freeze the round trip as a test:

it('round-trips a builder', function () {
    $original = Order::query()
        ->where('status', 'paid')
        ->whereIn('region', ['EMEA', 'APAC'])
        ->with('customer')
        ->orderByDesc('created_at')
        ->limit(100);

    $restored = \EloquentSerialize::unserialize(
        \EloquentSerialize::serialize($original)
    );

    expect($restored->toSql())->toBe($original->toSql())
        ->and($restored->getBindings())->toEqual($original->getBindings())
        ->and(array_keys($restored->getEagerLoads()))
            ->toEqual(array_keys($original->getEagerLoads()));
});

Extend it with a data provider covering every shape you actually use — whereHas, whereBetween, withCount, withTrashed, orderByRaw, paginate. Since the package documents no limitations, your test suite is the spec.

First real use case: take one nightly export or scheduled report whose selection logic already exists as a chained builder. Move the serialize() to where the builder is built and the unserialize() into a queued job. One query, one job, one test — green before you think about saved views, which require the envelope, validation and schema-drift handling below.

Implementation Patterns

Controller → Job, the canonical shape. Build where the domain context lives, execute where the work belongs. Pass the string, never the builder — Laravel's own job serializer is a different, less controllable path; don't mix the two.

// app/Http/Controllers/OrderExportController.php
public function store(ExportRequest $request): RedirectResponse
{
    $query = Order::query()
        ->where('status', 'paid')
        ->whereBetween('created_at', [$request->from, $request->to])
        ->with('customer', 'lines')
        ->orderBy('id');

    RunExport::dispatch(EloquentSerialize::serialize($query), $request->user()->id);

    return back()->with('status', 'Export queued.');
}
// app/Jobs/RunExport.php
class RunExport implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function __construct(
        private string $payload,
        private int $userId,
    ) {}

    public function handle(): void
    {
        EloquentSerialize::unserialize($this->payload)
            ->chunkById(500, fn ($orders) => /* stream rows out */ null);
    }
}

Note the chunkById in the worker, not get(). A deferred query is still a live query against a table that has grown since the payload was written.

A wrapper you own, not the facade in controllers. The single most valuable pattern: treat the package as a serialization primitive and expose your own seam. The envelope, validation, connection pinning and logging all live there — none of which the package provides.

// app/Support/QueryPayload.php
final class QueryPayload
{
    public static function from(Builder $builder, int $version = 1): string
    {
        return json_encode([
            'v'          => $version,
            'tenant'     => tenant()->id,
            'schema'     => self::schemaHash(),
            'builder'    => EloquentSerialize::serialize($builder),
            'created_at' => now()->toIso8601String(),
        ], JSON_THROW_ON_ERROR);
    }

    public static function toBuilder(string $envelope): Builder
    {
        $data = json_decode($envelope, true, flags: JSON_THROW_ON_ERROR);

        if ($data['schema'] !== self::schemaHash()) {
            throw new StaleQueryException();
        }

        return EloquentSerialize::unserialize($data['builder'])->on("tenant_{$data['tenant']}");
    }
}

Everything downstream calls QueryPayload::from() / toBuilder(). When the package is abandoned, or you swap to a DTO, the blast radius is one file.

Stored query definitions ("saved views"). Persist the envelope in a table you control, with the schema hash materialised as a column so you can query for stale rows without decoding every payload:

Schema::create('saved_queries', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->string('name');
    $table->text('payload');
    $table->unsignedSmallInteger('payload_version')->default(1);
    $table->string('schema_hash', 64);
    $table->timestamps();
});

Validate before executing, so a broken payload fails loudly instead of returning plausible-but-wrong rows:

public function run(SavedQuery $saved): LengthAwarePaginator
{
    if (! hash_equals($this->schemaHash(), $saved->schema_hash)) {
        throw new StaleQueryException($saved);
    }

    return EloquentSerialize::unserialize($saved->payload)->paginate(25);
}

private function schemaHash(): string
{
    $tables = ['orders', 'customers', 'order_lines'];

    return hash('sha256', json_encode(
        collect($tables)
            ->mapWithKeys(fn ($t) => [$t => Schema::getColumnListing($t)])
            ->all()
    ));
}

This is the cheapest insurance against the failure mode that actually hurts: a column rename six months after you persisted 4,000 payloads.

A deterministic key for a query shape. The serialized string is stable for "this exact query," which is handy for auditing, dedupe and invalidation:

$key = 'report:'.hash('sha256', EloquentSerialize::serialize($filteredQuery));

Be clear about what this buys you: it fingerprints the builder, not the rows. The database still executes on deserialize. If your goal is fewer DB round trips, Cache::remember is the tool; this package is for deferral and portability.

Tenant-scoped reuse. Resolve the connection explicitly inside the wrapper after unserializing (->on() in toBuilder() above), not at the call site. See Gotchas — you cannot assume the original ->on() survived.

Run it head-to-head with the DTO once. Keep a FilterDTO → toQuery() implementation in the repo and drive one real job through both for a sprint. The DTO is more code up front but is schema-change-tolerant, signable, and safe against untrusted input. Having both running against the same job makes the architectural decision cheap and gives you a pre-scoped exit path.

Gotchas and Tips

Serialized "now" is frozen "now." This is the one that bites teams in production. where('created_at', '>', now()->subDays(30)) binds a Carbon at serialize time. If the payload is a saved report executed daily, on day 7 it still means "the 30 days before day 1." Any query with a moving date window must bind that window at execution time — store a parameterised DTO alongside the payload, or rebuild the predicate after unserializing. Decide explicitly whether a payload is a frozen snapshot or a re-bound template; never let it be accidental.

Closures are the fragile part — find out which ones get dropped. where(fn ($q) => ...), with(['lines' => fn ($q) => $q->latest()]), constrained withCount — these carry PHP Closure objects. Test every one you use. A dropped eager-load constraint means N+1 in the worker; a dropped where means wrong rows. Always assert getEagerLoads() keys, and prefer plain with() over constrained eager loads where you can.

Check the wire format before the payload leaves your process. If it starts with a: or O:, it is PHP serialize()/unserialize(), and unserializing an attacker-influenced string is an object-injection primitive. Even when it is JSON, the payload names a model class, so a forged payload can point at any model in your app. Rule of thumb: payloads are internal-only. Store them only in rows your app writes, sign them if queue or DB access is shared, never echo them back to a client. If a user needs to control what runs, they author a DTO — they don't author a payload.

Global scopes drift silently. Model::query() bakes scopes in at build time. Whether a payload written before you added TenantScope picks it up on unserialize is undocumented and version-dependent. Test it: serialize, add a scope, unserialize, diff toSql(). Both failure modes — double-applied or missing — are worse than a crash, because a crash is visible and a scope leak is not. Fold a scope assertion into the schema_hash idea if you lean on global scopes.

Assume connection overrides are lost. The builder can hold a resolved Connection object rather than a name at serialize time. Don't rely on ->on('replica') round-tripping — call ->on($name) explicitly after unserialize(). This is exactly why connection pinning belongs in your wrapper rather than at the call site.

Watch payload size. A 10,000-element whereIn becomes 10,000 bindings inside the payload. SQS caps at 256 KB; Redis is roomy, but your queue driver can change under you. When payloads grow, store them (in saved_queries or a cache row) and pass the row ID to the job instead of the string.

Bindings are typed values. Carbon, BackedEnum, decimals — whether they survive depends on whether the payload is PHP serialize() (types preserved) or JSON (flattened). A BackedEnum silently becoming a string looks identical in toSql() and only blows up at bind time. Assert getBindings() equality in tests, not just toSql(), and compare with toEqual-style semantics rather than string casts.

Debug by diffing the two builders, not by reading the payload. One helper, used on both sides:

function diffBuilder(Builder $a, Builder $b): array
{
    return [
        'sql'      => [$a->toSql(), $b->toSql()],
        'bindings' => [$a->getBindings(), $b->getBindings()],
        'eager'    => array_keys($a->getEagerLoads()) === array_keys($b->getEagerLoads()),
    ];
}

Log that array at serialize time outside production and again in the worker. The package ships no telemetry — that log is your only signal when a "working" query quietly starts returning the wrong set.

Extension points are thin, so build your own seam. The facade resolves a singleton service; if you must override behaviour, bind your own class in a service provider and route through it. In practice the right extension point is the QueryPayload wrapper — treat the package as the primitive, not as the API your controllers call.

Pin the version and read the diff on upgrade. Single maintainer, ~117 stars, zero dependents. composer require anourvalar/eloquent-serialize:^X.Y, upgrade through CI, never straight to prod. Keep the wrapper seam so an abandonment is a one-file rewrite rather than a repo-wide refactor.

Out of scope by design: DB::table(), raw SQL pipelines, transactions, lockForUpdate(), and pagination state beyond limit/offset. If your query needs any of those, the DTO pattern is the answer — and that is the lower-risk default anyway.

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.
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai