lionix/castable-request
Laravel package for casting incoming request data into typed objects and structured values, helping keep controllers clean and reducing manual array handling when working with validated input.
Architecture fit. The package slots into the seam most Laravel services already have but implement inconsistently: the FormRequest → controller boundary. Instead of a base request class, a trait, or manual hydration in the controller, request attributes are declared in a casts() map and materialised as enums, dates, DTOs, value objects, or collections — including nested and wildcard paths (items.*.id). Architecturally this reinforces the thin-controller / typed-boundary pattern we already want, and it removes an undifferentiated piece of plumbing that tends to drift between teams. The 2.0.0 dependency surface is narrower than the description implies: illuminate/database, illuminate/http, illuminate/support instead of the full laravel/framework. That is a smaller SBOM and audit footprint than an in-house trait that reaches into framework internals, and it makes the build-vs-buy case defensible.
Where it does not fit. Any service on Laravel ≤11 is out — 2.0.0 requires PHP ^8.2 and Laravel ^12|^13, and the maintainers explicitly refuse Laravel 11 on security grounds. Applications that depend on the old ordering (cast first, validate the cast value) will behave differently at runtime. Applications that read $request->input() or $request->all() downstream expecting scalars will now see objects and enums in that array, and Symfony's InputBag rejects non-stringable objects. Any installation with a customised Handlers\RequestResolvingHandler has code that no longer has a home.
Integration feasibility. The public surface is small and documented as contracts: CasterInterface, CastsRegistryInterface, RequestInputCasterInterface, plus a single RequestAfterResolvingHandler that now applies both global and request casts. On a greenfield service this is an afternoon. On a mature service the install is trivial and the audit is the work: every rules() method, every prepareForValidation(), every custom rule, every strict comparison against validated payloads, and every exception-handler mapping that converts caster throws into 422s. Budget one sprint for a mid-sized service with broad request usage, weighted toward audit and regression testing rather than code change.
Technical risk. Ordered by blast radius:
Carbon instance satisfying date) will start failing — or worse, passing for the wrong reason. Upside: bad input becomes a 422 field error instead of a 500. Downside: it changes the public error contract.validated() and safe() now return cast values. Convenient, but strict comparisons ('pending' === $data['status']) silently change truthiness. This fails quietly in tests that assert on response shape only.ImMemoryCastsRegistry → InMemoryCastsRegistry, and the removed RequestResolvingHandler. These are fatal errors, not warnings, and a stale name hiding in a container binding or test bootstrap is easy to miss with a use-statement-only search.EloquentModelCaster no longer extends Model. Any type-hint, model method call, or persistence through the caster is broken.Mitigation for (5) is cheap and worth doing regardless: put the package behind a one-class internal adapter (or at minimum confine all casts() declarations and registry calls to a documented convention), so a swap back to hand-rolled hydration is a bounded change rather than a rewrite.
Key questions to close before committing:
composer why-not lionix/castable-request ^2.0 resolve cleanly in each target repo, or do we have pins that conflict with the split illuminate/* requirement?rules() methods in the affected services currently rely on cast-before-validate semantics? (Grep for Rule::enum alongside date, and for rules receiving objects.)forget() calls become a hazard.Stack fit. Two hard gates and one soft gate. Hard: PHP ^8.2 and Laravel ^12|^13. Soft: whether the service is greenfield or brownfield — greenfield should adopt the casts() convention from day one with no migration cost; brownfield should treat this as a refactor with a regression-test budget. Run composer why-not per repo first; because 2.0.0 requires the split illuminate/* packages rather than laravel/framework, version conflicts surface at dependency resolution instead of as a runtime surprise. That is a genuine improvement over the 1.x arrangement and makes the go/no-go decision fast.
Migration path. Follow the package's own ordering, which is also the cheapest ordering:
UPGRADE.md end to end — it is short and enumerates every breaking change.ImMemoryCastsRegistry, RequestResolvingHandler, and any custom handler customisation. Fix bindings and test bootstrap, not just use statements.rules() method against the raw wire format. Replace date with date_format:... where a cast is what normalises a non-standard string.try/catch blocks around casting, exception-handler mappings that turned InvalidArgumentException into a 422, wildcard index-keyed workarounds, and manual forget() calls added to work around cross-request leakage.prepareForValidation(), custom rules, and middleware that assumed input() was already typed.Compatibility. After the fix for registry leakage, the package is safe across queued jobs, Octane workers, and long-lived test processes — that was the 1.x failure mode and it is the strongest argument for taking 2.0 rather than pinning an older line. JSON-like casts (array, json, object, collection) now tolerate already-decoded input, which matters for feature tests that construct requests by hand, internal sub-requests, and JSON bodies parsed upstream. Mirror the package's own CI matrix — HTTP-level tests, Pint, Larastan across every supported PHP/Laravel pair — in our pipeline; given how small the dependent base is, that matrix is our cheapest early-warning system for the ordering and typing regressions above.
Sequencing. Five phases, with an explicit exit gate on each:
composer why-not is clean. This is the roadmap-critical dependency; the package cannot be scheduled in parallel with a framework upgrade.rules(), prepareForValidation(), downstream strict comparisons, handler customisations, and registry bindings. Output: a per-service risk list and an effort estimate. This phase can kill the initiative cheaply.CastsRegistryInterface::register(), per-request overrides in casts() (request always wins), and castAttributes() for batch casting outside the form-request lifecycle.forget() at runtime, no merge() of cast values, no strict comparisons against validated(). Wire the CI matrix.Maintenance. Small dependency tree, MIT licence, no committed composer.lock upstream, and a release cadence tied to Laravel majors (2.0.0 explicitly targets Laravel 13). Pin ^2.0 and treat the package as a versioned internal dependency. The real maintenance risk is bus factor: one maintainer, 23 stars, zero dependents means no crowd-sourced bug discovery. Allocate roughly 0.1 FTE ongoing for monitoring releases, verifying against our Laravel upgrade cadence, and being prepared to vendor. That is still cheaper than owning an in-house casting layer, but it is not zero and should be stated as such rather than assumed away.
Support. Net positive for support load: malformed enums and unparsable dates become 422 field errors instead of 500s from inside a caster. Support runbooks should be updated to reflect that, and the API docs and any client SDKs need to match before rollout — a status-code change is a user-visible contract change, not an internal refactor, and it belongs in a product/API review. The corresponding negative is a class of quiet regressions (strict comparisons against validated()) that will not page anyone and will surface as wrong behaviour, so expect a short tail of tickets after rollout rather than at rollout.
Scaling. Two concrete wins. First, request input is replaced once per request instead of once per casted attribute, and castAttributes() casts several attributes in a single pass — prefer it over looping castAttribute(), both for throughput and to avoid a half-cast intermediate input array. Second, Octane and queue-worker safety is restored, so we can run the package on a long-lived worker fleet without the 1.x cross-request leakage. Watch payload size on wildcard casts over large lists: wildcards are applied per item, and the fixed per-item custom-caster bug means custom casters now do real per-item work rather than silently reusing the first result.
Failure modes to document in the runbook.
InvalidArgumentException from InputBag — caused by writing cast values back with $request->merge(). Read from validated()/safe(); never merge cast output.forget() at runtime. forget() is a test-isolation tool; in tearDown(), in preference to re-registering dummy values. Override per request instead of mutating the singleton.'next tuesday', offset-bearing timestamps) still produce the expected instances, especially in queue workers and console commands that never open a DB connection.RequestResolvingHandler, that customisation must move; the two-phase resolve/after-resolve split no longer exists.Ramp-up. Engineers need roughly half a day to internalise the validation-then-cast mental model; the reference material is UPGRADE.md plus the three contracts, both of which are good. The gap is the absence of a community corpus — no tutorials, no Stack Overflow answers, no blog posts from other adopters. Budget for an internal write-up (the ADR plus cookbook from Phase 3) and treat it as a deliverable, not documentation debt. New joiners will otherwise re-derive the ordering rules and the wildcard-shadowing behaviour from the source.
How can I help you explore Laravel packages today?