carsdotcom/laravel-json-model
A Laravel package for working with JSON-backed models, letting you define structured JSON data as Eloquent-style models and query or persist it through familiar Laravel conventions. Useful for flexible schemas, configuration, and document-style storage.
Install and pin. The package is Composer-only and past 1.0 (v1.4.10), but it has a single star and thin public docs. Pin explicitly rather than floating:
composer require carsdotcom/laravel-json-model:^1.4
Check the installed composer.json for the Laravel/PHP constraint matrix before assuming your app version is supported — with a low-adoption package, the constraint file is the support matrix.
Where to look first. In order:
vendor/carsdotcom/laravel-json-model/src/ — find the base model class and the attribute-definition contract. This is the real documentation.vendor/carsdotcom/laravel-json-model/tests/ — the test suite is the most reliable usage example; copy its patterns rather than improvising.json / longText column with an array cast. That is the migration target.First use case. Take a settings or profile JSON column currently cast as array and accessed as $user->settings['notifications']['email']. Replace the array cast with a JSON-backed model so the shape is typed, defaults are defined in one place, and nested access is explicit. Do this for one column first and run the existing test suite; JSON-backed models change how dirty tracking and serialization behave, so a single-column pilot surfaces the surprises cheaply.
Smoke test. Write a failing-then-passing test that (a) reads a default value from an empty JSON column, (b) mutates a nested attribute, (c) saves and re-reads the parent model, and (d) asserts the raw column round-trips. If that test does not pass cleanly, stop and read the source before rolling out.
Wrap one column, don't replace Eloquent. The JSON model is a value object cast onto a parent Eloquent model's column — it is not a table-backed model of its own. Keep the parent (User, Account, Project) as the thing you query, and treat the JSON model as the typed shape of one column.
// parent model
protected $casts = [
'settings' => SettingsModel::class, // confirm class/base name in src/
];
Define defaults in the model, not the migration. The main day-to-day win is a single source of truth for the JSON document shape. New accounts get sane defaults without a data migration, and reads of a null column don't require ?? [] guards scattered through the codebase.
Mutate through the model, then reassign. Nested mutation is where JSON casts usually go wrong. Read the model, mutate it, and assign it back to the parent attribute before save() so Eloquent's dirty check fires:
$settings = $user->settings;
$settings->notifications->email = false;
$user->settings = $settings;
$user->save();
Relying on in-place mutation of a nested object is the most common cause of "the change didn't persist."
Validation at the boundary. Validate the incoming payload in a FormRequest, not inside the JSON model. Use the JSON model for shape and casting; use Laravel validation for user input. If the package offers validation helpers, treat them as a second line of defence for programmatic writes.
Query the parent, not the document. Filtering inside JSON is a database concern. Use the parent model's query builder with JSON path syntax (where('settings->notifications->email', true) on MySQL/Postgres) rather than loading rows and filtering in PHP. Confirm dialect support — SQLite's JSON support is weaker and will silently diverge from production.
Keep nesting shallow. Two or three levels is fine; a deeply nested document becomes a schema you can't query, index, or migrate. If a subtree grows its own lifecycle, it wants a table, not a deeper JSON model.
Test with the real driver. Use the same database engine in CI as in production for any test that touches JSON path queries; SQLite in-memory will pass where MySQL behaves differently.
Adoption risk is the headline. One star, thin docs, and a release history dominated by a Larastan annotation fix. Budget time to read the source, and wrap the package behind your own small base class or interface so a future swap is a one-file change rather than a codebase-wide refactor.
Larastan 3.11 coupling. v1.4.10 is annotations-only and targets Larastan 3.11. If your CI pins an older Larastan, the fix is inert; upgrading Larastan may surface new errors in your own code. Treat the package bump and the Larastan bump as one tracked change, not two independent composer update side effects.
Dirty tracking on nested structures. JSON columns are compared as arrays/objects at save time. Any mutation that doesn't change the parent attribute's value won't persist. The reliable pattern is always read → mutate → reassign → save. Add a test that asserts persistence, not just in-memory state.
Concurrent writes are last-write-wins. Two requests editing different keys of the same JSON column will clobber each other, because the whole document is written back. If multiple features write the same column, either serialize writes with lockForUpdate() inside a transaction or split the column per feature. This is the failure mode most likely to bite in production and least likely to show up in tests.
Indexing is limited. MySQL cannot fully index a JSON column; you can generate a column or use functional indexes, but a where on settings->... may table-scan. If you query a JSON field often, promote it to a real column.
Don't put secrets or large blobs in the document. JSON columns are returned wholesale on select *; a settings document is fine, an audit log is not.
Debugging tip. When a value looks wrong, dd($model->getRawOriginal('settings')) to see the stored JSON versus what the cast produces. Most bugs are a cast/parse mismatch, not a package bug.
Extension points. The base model's attribute-definition contract and cast methods are the places to hook custom casting or validation. Subclass your own base JSON model that extends the package's and put team conventions (defaults, helper accessors, serialization rules) there — that gives you one place to patch if the package stalls or is abandoned.
Version discipline. Pin ^1.4 and commit the lockfile. Do not adopt this package for a schema you can't afford to migrate later; for a low-stakes settings blob it's a reasonable ergonomic win, for core business data it is a liability without a maintainer commitment.
How can I help you explore Laravel packages today?