oza/laravel-database-jsonable
A Laravel package that adds JSON column support to Eloquent models, making it easy to store, retrieve, and cast structured JSON data in your database without custom accessors or mutators.
Install
composer require oza/laravel-database-jsonable
No service provider or config publish is required for typical use — the package is a trait you attach to Eloquent models.
Wire up a model
use Illuminate\Database\Eloquent\Model;
use Oza\Jsonable\Traits\Jsonable;
class Post extends Model
{
use Jsonable;
protected $jsonable = ['meta', 'settings'];
}
Migration for the backing columns
Schema::create('posts', function (Blueprint $table) {
$table->increments('id');
$table->string('title');
$table->json('meta')->nullable(); // or ->text() on MySQL < 5.7
$table->json('settings')->nullable();
$table->timestamps();
});
First use case Store arbitrary, schema-less payloads on an existing row without spinning up a new table:
$post = Post::create(['title' => 'Hello']);
$post->meta = ['seo' => ['title' => 'Hello | Acme'], 'tags' => ['php', 'laravel']];
$post->save();
$post->fresh()->meta['seo']['title']; // "Hello | Acme"
The first place to look after install is the trait source in vendor/oza/laravel-database-jsonable/src — it's small, and confirming the exact property name ($jsonable) and whether it hooks getAttribute/setAttribute vs. magic mutators tells you everything about how it will interact with your existing accessors.
Declare all JSON-backed columns in one place
protected $jsonable = ['meta', 'settings', 'preferences'];
Keep this list in sync with your migrations. A column in the list but not in the DB will throw on save; a JSON column not in the list will be returned as a raw string.
Assign arrays, read arrays
$post->settings = ['theme' => 'dark', 'notifications' => true];
$post->save();
$theme = $post->settings['theme']; // decoded automatically
$post->settings = array_merge($post->settings ?? [], ['lang' => 'en']);
Treat assignment and read as whole-value operations. This is the cleanest mental model and avoids surprises with reference semantics.
Defaults for new records
Provide sane defaults via a model boot/creating hook rather than relying on DB defaults, since the trait decodes on read:
protected static function booted()
{
static::creating(fn ($post) => $post->settings ??= ['theme' => 'light']);
}
Pair with validation The package does not validate shape. Do it in a FormRequest and pass the validated subset through:
$post->meta = $request->validated()['meta'] ?? [];
Expose cleanly through API resources Because the value is already an array, resources just work:
return [
'id' => $this->id,
'meta' => $this->meta, // already an array
];
No manual json_decode in your transformer layer.
Query the JSON in SQL, not in PHP Filtering should happen in the database when the driver supports it:
Post::whereJsonContains('meta->tags', 'laravel')->get(); // MySQL 5.7+ / PG jsonb
Post::where('meta->seo->title', 'like', '%Acme%')->get();
The trait only handles hydration; it does not translate array conditions into SQL.
Combine with native casts where it makes sense
If a column is a single field, prefer Laravel's native cast (protected $casts = ['meta' => 'array']). Use this package when you have several JSON columns and want one declarative list instead of repeating cast entries, or when you're on an older Laravel that predates native JSON casting.
Factories and seeders Set arrays directly — the trait encodes on save:
Post::factory()->create(['meta' => ['tags' => ['seed']]]);
Laravel's native casts overlap heavily. Since Laravel 5.x, $casts = ['meta' => 'array'] (and json, collection, object) does the same job. Adopt this package only if the single $jsonable list genuinely simplifies your model, or you're maintaining a codebase already using it. Do not mix both mechanisms on the same column — double-encoding ("\"{...}\"") is the usual symptom.
Nested mutations may not persist. In many implementations of this pattern, $post->meta['tags'][] = 'new'; $post->save(); does not mark the attribute dirty because PHP returned a copy. Force it:
$meta = $post->meta;
$meta['tags'][] = 'new';
$post->meta = $meta;
$post->save();
Always verify with $post->getDirty() before trusting a save.
Column type matters. On MySQL 5.6 or older, json columns don't exist — use text/longText. On SQLite (tests) the JSON type is just text, and whereJsonContains behaves differently. Add an integration test against your production driver, not just sqlite.
Null vs. empty array. $post->meta = null and $post->meta = [] are different stored values (NULL vs "[]"), and they decode to different PHP values. Pick a convention and stick to it — most bugs come from null sneaking in where code assumes an array. Use ?? [] defensively when reading.
No schema enforcement means silent drift. Two code paths can write incompatible shapes to the same column. Lock the shape down with a DTO or spatie/laravel-data if the field grows beyond trivial metadata.
Whole-column writes = last-write-wins. Two concurrent requests updating different keys in the same JSON field will clobber each other. For anything contention-prone, promote those keys to real columns.
Debugging: dd($post->meta) shows the decoded array — to see what's actually stored, bypass Eloquent:
DB::table('posts')->where('id', $id)->value('meta');
If you see escaped quotes there, something upstream already encoded the value.
Extension point: if you need per-column behavior (encryption, custom decoding, a cast class), override the accessor/mutator on the model for that attribute — it will take precedence over the trait — or migrate the column to a native cast and drop it from $jsonable.
Vendor risk. With few stars and low activity, treat this as a thin convenience layer you could replace in an afternoon. Keep the $jsonable list small and the usage shallow so migrating to native casts later is mechanical rather than a refactor.
How can I help you explore Laravel packages today?