Catch JSON bugs in CI, not production
A misspelled or missing key fails PHPStan/Larastan instead of paging you at 2am. The shape's @phpstan-type block is the single source of truth.
Describe a JSON column once, and Eloquent hands you a typed object — autocomplete, static analysis, and methods — without giving up the array you already know.
You reach for a JSON column because the data is structured but doesn't deserve its own table — user preferences, a cached API response, audit context, a feature-flag payload. It works. Then six months later:
$user->preferences['notifcations']['email']; // typo. ships green. pages you at 2am.Eloquent hands JSON columns back as a plain array — so no autocomplete, no static analysis, no guarantees. The structure lives only in your head and in a migration comment nobody reads. Every access is a leap of faith, and the answer arrives in production as an undefined array key deep inside a controller.
JsonShape turns that array into a typed object you define once, while staying a thin wrapper you can still treat like an array.
$user->preferences->theme; // string — autocompleted, type-checked
$user->preferences->emailEnabled(); // bool — and that 2am typo? now a PHPStan errorSame column. Same database. Your editor and your CI now catch the mistakes you used to ship.
Who is this for?
Laravel developers who store structured data in JSON columns — settings, metadata, cached API payloads, audit context, feature flags, traces — and who run PHPStan/Larastan and want that data held to the same standard as the rest of their typed codebase.
The honest answers to the questions developers ask before adopting:
json_decode, and data_get() scattered across the model. JsonShape is one cast and one class; reads and writes flow through it automatically.JsonShape pays off anywhere a JSON column has a knowable shape read in more than one place:
preferences, notification settings, theme and locale.Install via Composer. Requires PHP 8.4+ and Laravel 13.
composer require plumthedev/json-shapeINFO
Support for older PHP and Laravel versions is on the way.
Extend JsonShape and expose typed accessors. Property hooks read straight from the underlying $attributes, and the @phpstan-type / @extends annotations make the whole thing type-safe under static analysis.
<?php
namespace App\Shapes;
use Plumthedev\JsonShape\JsonShape;
/**
* @phpstan-type TraceJsonShape array{
* traceId: string,
* message: string,
* duration?: int,
* context: array{
* userId: string,
* levelNumber: int,
* },
* }
*
* @extends JsonShape<TraceJsonShape>
*/
class TraceShape extends JsonShape
{
// Typed read access via a property hook.
public string $traceId {
get => $this->attributes['traceId'];
}
// A plain typed getter works just as well.
public function getMessage(): string
{
return $this->attributes['message'];
}
// Lean on Laravel's Fluent for coercion and defaults.
public function getDuration(): int
{
return $this->fluent->integer('duration');
}
// Fluent setter: tap() mutates and returns $this for chaining.
public function setContext(array $value): self
{
return $this->tap(fn () => $this->attributes['context'] = $value);
}
}Point an Eloquent cast at your shape and the JSON column is decoded into a TraceShape on read and encoded back to JSON on save.
<?php
namespace App\Models;
use App\Shapes\TraceShape;
use Illuminate\Database\Eloquent\Model;
use Plumthedev\JsonShape\Casts\AsJsonShape;
/**
* @property TraceShape $trace
*/
class Example extends Model
{
protected $fillable = ['trace'];
/** @return array<string, mixed> */
public function casts(): array
{
return [
'trace' => AsJsonShape::of(TraceShape::class),
];
}
}INFO
Need a generic, untyped shape instead of a dedicated class? Cast with AsJsonShape::class and you'll get a plain JsonShape back.
$example = Example::find(1);
// Read with autocomplete and types.
$example->trace->traceId; // string
$example->trace->getDuration(); // int
// Write through your setters — chainable.
$example->trace
->setContext(['userId' => 'e92fd2c9', 'levelNumber' => 16]);
$example->save();You can also build shapes by hand, combine them, or copy them without touching the original:
use App\Shapes\TraceShape;
$trace = TraceShape::make([
'traceId' => 'abc-123',
'message' => 'Request handled',
'context' => ['userId' => 'u-1', 'levelNumber' => 1],
]);
$trace = TraceShape::fromJson($jsonString); // from a raw JSON string
$trace = TraceShape::empty(); // a blank shape
$trace->merge(['context.userId' => 'u-2']); // dot-notation merge
$copy = $trace->clone(); // independent deep copyJsonShape is the right tool for typed Laravel JSON columns, not every JSON problem.
Reach for it when:
json_decode across your models.Skip it when:
array/collection cast is simpler.We'd rather you not install it than fight it — if your case is in the "skip" column, that's a good outcome too.
Laravel gave us first-class casts, property hooks, and a strong static-analysis story with Larastan. Typed JSON columns are the obvious next step — and there's no agreed-on, ergonomic way to do it yet. JsonShape is an opinionated attempt to define that pattern: types PHPStan enforces, an object that still feels like the array underneath, and as little machinery as possible in between.
It's early, and the conventions are still being shaped — which is exactly why input is welcome. Found a bug, or have a strong opinion about how typed JSON in Laravel should feel? Open an issue or a pull request on GitHub.
The usage guide is a set of short chapters that walk through one running example end to end: