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.
Architecture fit. The package occupies a narrow, well-defined seam: it converts an Eloquent Builder into a portable string and back. It is not a query cache, not a repository layer, and not a job framework — it is a serialization primitive. That makes it architecturally safe in the sense that it does not impose structure on the application, but it also means it delivers no architecture. The value is entirely in the use cases it unlocks: controller-builds-query / worker-executes-query, stored query definitions ("saved views"), and deterministic keys for query shapes. For a team already running Laravel 6–12 with queue-based workloads (exports, scheduled reports, deferred notifications), the fit is direct: one serialize() at the point of domain context, one unserialize() where the work belongs. For a team without deferred-execution needs, there is no fit — this is not a library you adopt speculatively.
Integration feasibility. Installation is a single Composer require with auto-discovered service provider, no config publishing, no migrations, no configuration surface. The public API is two facade methods. This is about as low a technical barrier as a PHP package can present; a competent Laravel engineer can be productive in an afternoon. The cost is not in integration, it is in the wrapper the team must write around it — the package provides no envelope, no versioning, no validation, no connection pinning, no telemetry. Everything the PM would consider "production readiness" is the adopter's responsibility, and that responsibility is easy to underestimate at kickoff.
Technical risk. Four risks dominate, and they are the ones a TPM should track explicitly:
QueryPayload or equivalent) so an abandonment is a one-file rewrite rather than a repo-wide refactor.where(fn...), constrained with, constrained withCount) may be silently dropped; global scopes may double-apply or vanish; connection overrides (->on('replica')) may be lost. Each of these produces plausible-but-wrong rows, not an exception. This is the highest-severity risk class and it is invisible to monitoring unless the team asserts on toSql(), getBindings(), and getEagerLoads() in tests.now() bound at serialize time stays frozen at execution time. A saved report persisted on day 1 and executed on day 7 still means "the 30 days before day 1." Every payload must be explicitly classified as either a frozen snapshot or a re-bound template; leaving this accidental is how a reporting feature ships with a silent off-by-a-week bug.a:/O: implies PHP serialize()), unserializing an attacker-influenced string is an object-injection primitive; even in JSON form the payload names a model class, so a forged payload can target any model in the app. The product rule must be written down: payloads are internal-only, never round-tripped to a client, never authored by users.Key questions to resolve before commitment.
toQuery() path is schema-change-tolerant, signable, and safe against untrusted input. The package makes the serialized-builder branch cheap enough to price honestly, not automatically correct.serialize() output at all, or do we constrain to JSON-encodable state?Stack fit. Requires Laravel 6–12 and PHP with Composer. Works with any Eloquent model query, including eager loading, where clauses, and limit/offset. Out of scope by design: DB::table() query builder, raw SQL pipelines, transactions, lockForUpdate(), and pagination state beyond limit/offset. If our workload depends on any of those, the DTO path is the answer and the package is not applicable — better to establish that at evaluation time than at implementation time. Queue compatibility matters too: the serialized string is passed as job payload, which introduces queue-driver size limits (SQS at 256 KB is the constraint to design against).
Migration path. There is no migration in the schema sense — no config, no tables, nothing to publish. The migration is a code migration and should be additive:
^X.Y), never *, and lock it in CI.QueryPayload wrapper (from() / toBuilder()) with envelope version, tenant, schema hash, timestamp, and explicit ->on($connection) on the way out. This is the deliverable that matters; the package itself is a two-line dependency.whereHas, whereBetween, withCount, withTrashed, orderByRaw, and pagination.saved_queries table, schema-hash validation, and staleness handling.Compatibility. Laravel version coverage is broad (6–12), which reduces upgrade friction on the framework side. The compatibility risk is internal: builder state that does not survive the round trip, and typed bindings (Carbon, BackedEnum, decimals) whose fidelity depends on whether the wire format is PHP serialize() (types preserved) or JSON (flattened). A BackedEnum silently becoming a string looks identical in toSql() and only fails at bind time — hence the requirement to assert on getBindings() equality, not just SQL. Connection overrides should be treated as universally lost and reapplied explicitly by the wrapper.
Sequencing. Ship in this order, with each step independently valuable and independently revertible:
tinker, diff toSql(), getBindings(), getEagerLoads(). Read vendor/anourvalar/eloquent-serialize/src/ — it is small and it is the only reliable documentation of what state is preserved. In parallel, keep a FilterDTO → toQuery() implementation and drive one real job through both. This head-to-head is what makes the architectural decision cheap and gives us a pre-scoped exit path.Each phase should carry a go/no-go on the DTO comparison. If the DTO path proves only marginally more expensive by Phase 2, we should be prepared to standardize on it.
Maintenance. Two distinct maintenance loads. The first is trivial: dependency pinning and a CI upgrade with diff review. The second is the real one — the round-trip test suite is our specification, so every new query shape we serialize (a new constrained with, a new withCount, a new scope) requires a test to confirm it survives. That is a standing tax on the team that owns deferred execution, and it should be assigned to a named owner rather than absorbed by whoever touches the feature. The wrapper seam caps the maintenance blast radius: when we drop or replace the package, one file changes.
Support. No vendor support, no SLA, no documented limitations, no telemetry. The upstream README is a smoke test. Internal support capability has to be built: a diffBuilder() helper that logs SQL/bindings/eager-load parity at serialize time (outside production) and again at unserialize time in the worker. That log is our only signal when a "working" query starts returning the wrong set. Onboarding documentation should be written by us, not pointed at upstream. The single-maintainer profile also means we should watch for abandonment and be prepared to fork — the MIT license and small footprint make that viable, and the wrapper makes it survivable.
Scaling. Serialization itself is cheap; the scaling questions are payload size and execution placement. Payload size scales with whereIn cardinality — a 10,000-element whereIn becomes 10,000 bindings embedded in the string. The design pattern is to store large payloads (a saved_queries row or cache entry) and pass the row ID to the job rather than the payload itself. That also solves queue-driver portability, since Redis is roomy but the driver can change under us and SQS caps at 256 KB. Execution scaling is unchanged from any other Eloquent work — the package caches the builder, not rows; the database still executes on deserialize. Teams expecting fewer DB round trips will be disappointed; the correct tool for that is Cache::remember.
Failure modes. Ranked by how badly they hurt, not by likelihood:
where), dropped eager-load constraints (N+1 in the worker), or drifted global scopes (a tenant scope double-applied or missing). No exception, no alert, plausible-looking data. This is the failure mode that erodes trust in the feature; it is mitigated only by bindings/eager assertions in tests and parity logging.schema_hash stored alongside the payload and checked before execution, throwing loudly instead of returning plausible-but-wrong rows.unserialize(). Mitigation is policy and code review, not tooling.Ramp-up. Low for the API, moderate for the judgment. Any Laravel engineer can call two facade methods in an afternoon; knowing which builder state survives takes reading the source and writing tests, and knowing which payloads are frozen vs. re-bound is a product decision that engineering cannot make alone. Realistic ramp: half a day for the API, one sprint for a team to be genuinely confident with one workflow in production. Onboarding should start from the wrapper and the test file, not the README, and should include the explicit rule set — payloads are internal-only, connections pinned on read, date parameters re-bound unless the payload is deliberately a snapshot.
Recommendation. Adopt as a primitive behind an owned wrapper, scoped initially to one deferred-execution workflow, with a DTO comparison run in parallel during the first sprint. Do not adopt it as a general mechanism for user-authored saved views until the schema-hash guard, signing decision, and round-trip test coverage are in place. The package's opportunity score (63.6) reflects a genuine gap — there is no mainstream, well-supported way to persist an Eloquent builder — but a 117-star, single-maintainer dependency should be treated as an accelerant for a decision we would otherwise make ourselves, not as infrastructure we inherit.
How can I help you explore Laravel packages today?