Chapter 1 — Define a shape
The question: how do I describe the structure of a JSON column so the rest of my code can rely on it?
A shape is a small class that gives a JSON structure a name, a home, and a type. You write it once; from then on every read and write goes through it, and your IDE and PHPStan know exactly what's inside.
Why a class — not just an array?
A JSON column decoded into an array has no name and no contract. The structure lives in your head and in a migration comment. JsonShape moves that knowledge into one place — a class — so that:
- the structure has a single source of truth every part of your app can point at;
- PHPStan can enforce it instead of trusting you to remember the keys;
- your editor can autocomplete fields and methods.
That's the whole idea. Everything else in this guide is detail.
The smallest possible shape
Extend JsonShape, declare the structure in a @phpstan-type block, and wire it in with @extends:
<?php
namespace App\Shapes;
use Plumthedev\JsonShape\JsonShape;
/**
* @phpstan-type TraceJsonShape array{
* traceId: string,
* message: string,
* }
*
* @extends JsonShape<TraceJsonShape>
*/
class TraceShape extends JsonShape
{
public string $traceId {
get => $this->attributes['traceId'];
}
}Two annotations are doing the work:
@phpstan-type TraceJsonShape array{...}is the contract — the shape's single source of truth. It's a docblock, so it adds zero runtime cost; the structure lives entirely in PHPStan's type system, which is the right place for it since the data itself is dynamic JSON.@extends JsonShape<TraceJsonShape>plugs that contract into static analysis, so every$this->attributes[...]access inside the class is checked against it.
The traceId accessor is a PHP 8.4 property hook. It's just a teaser here — Chapter 2 covers every way to expose a value.
The full running example
The rest of the guide grows TraceShape into something realistic: required fields, an optional one, a nullable one, an enum, and a nested object. Here's the complete contract we'll build against:
/**
* @phpstan-type TraceJsonShape array{
* traceId: string,
* spanId: string,
* level: value-of<LogLevelEnum>,
* message: string,
* timestamp: string,
* exception?: string,
* stackTrace: string|null,
* context: array{
* userId: string,
* levelNumber: int,
* },
* duration?: int,
* }
*
* @extends JsonShape<TraceJsonShape>
*/
class TraceShape extends JsonShape
{
// accessors added over the next chapters…
}Each field demonstrates one kind of declaration, and Chapter 6 explains exactly what each one means to PHPStan. The level field is backed by a plain PHP enum:
<?php
namespace App\Shapes;
enum LogLevelEnum: string
{
case ALERT = 'alert';
case INFO = 'info';
case DEBUG = 'debug';
}You don't new a shape
JsonShape's constructor is intentionally protected and final, so this won't work:
$trace = new TraceShape([...]); // ✗ won't compileShapes are created through factory methods (make(), fromJson(), empty()) or — most often — by an Eloquent cast when a model loads. This keeps construction consistent and gives the package one place to decode JSON. You'll use both paths soon; for now just know that's the rule.
Best practices
- Name shapes
XxxShapeand keep them in a dedicated namespace (App\Shapes) so they're easy to find and obviously not models or DTOs. - Treat the
@phpstan-typeblock as the source of truth. When the JSON structure changes, change it here first — analysis will then point you at every accessor that needs updating. - Don't add fields you never read. A shape should describe the part of the JSON your app actually uses, not mirror every key for completeness.