php-standard-library/type
Runtime type validation for PHP built on the Parse, Don't Validate principle: coerce and assert unstructured input into well-typed data, with clear errors when values don't match.
Install the split package directly, or get it transitively via the umbrella library:
composer require php-standard-library/type
# or, if you already use the metapackage:
composer require php-standard-library/standard-library
Everything lives under the Psl\Type namespace, exposed as namespaced functions that build immutable type specifications:
use Psl\Type;
$spec = Type\shape([
'name' => Type\string(),
'age' => Type\int(),
'tags' => Type\vec(Type\string()),
]);
$data = $spec->coerce(['name' => 'Ada', 'age' => '36', 'tags' => ['php', 'laravel']]);
// ['name' => 'Ada', 'age' => 36, 'tags' => ['php', 'laravel']]
The two methods you will use on every spec are:
coerce(mixed $value) — accepts loosely typed input and attempts to produce the target type; throws Psl\Type\Exception\CoercionException when it cannot.assert(mixed $value) — no coercion; throws Psl\Type\Exception\AssertException if the value is not already exactly the right type.First place to look: the docs site for the Type chapter, then src/ in the package for the concrete specs (shape, vec, dict, union, nullable, optional, instance_of, literal_scalar, …). Read the signatures of Type\shape() and Type\optional() first — they cover the majority of real Laravel payloads.
First practical use case: parse a JSON request body at the controller/service boundary into a shape you can trust, instead of sprinkling is_array() / is_string() checks through the call stack.
$payload = Type\shape([
'email' => Type\string(),
'password' => Type\string(),
'profile' => Type\optional(Type\shape([
'bio' => Type\string(),
])),
])->coerce($request->all());
After this line, downstream code receives a well-shaped array (or a DTO built from it) and should not need to re-check types.
Parse once in a DTO factory. The highest-leverage pattern is a fromArray() that owns its own spec and returns a typed object. Controllers and jobs should call the factory, not re-check types.
final class CreateUser
{
public function __construct(
public readonly string $name,
public readonly int $age,
public readonly array $tags,
) {}
public static function fromArray(array $raw): self
{
$data = self::type()->coerce($raw);
return new self($data['name'], $data['age'], $data['tags']);
}
public static function type(): Type\TypeInterface
{
static $type;
return $type ??= Type\shape([
'name' => Type\string(),
'age' => Type\int(),
'tags' => Type\vec(Type\string()),
]);
}
}
FormRequest: parse in passedValidation(). Laravel's rules still give you field-level messages and all the required/email/exists checks. Use the type spec afterwards to convert the validated bag into a typed shape.
final class StoreUserRequest extends FormRequest
{
private array $parsed;
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'age' => ['required', 'integer', 'min:0'],
'tags' => ['array'],
'tags.*' => ['string'],
];
}
protected function passedValidation(): void
{
$this->parsed = Type\shape([
'name' => Type\string(),
'age' => Type\int(),
'tags' => Type\vec(Type\string()),
])->coerce($this->validated());
}
public function payload(): array
{
return $this->parsed;
}
}
Use coerce() here, not assert(): form input arrives as strings even after integer validation, so assert() would throw on '36' while coerce() succeeds.
Bridge to a Laravel validation rule for non-HTTP boundaries. When you have a spec that is hard to express as rules (nested shapes, unions, instance_of), wrap assert() in a rule object so errors surface through the normal validation pipeline.
final class TypeRule implements ValidationRule
{
public function __construct(private readonly Type\TypeInterface $type) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$this->type->assert($value);
} catch (Type\Exception\AssertException $e) {
$fail($e->getMessage());
}
}
}
Queue jobs and console commands. Jobs receive arrays from serialize() and CLI options arrive as strings. Parse at the top of handle() / execute() and fail fast; do not propagate mixed deeper into the job.
public function handle(): void
{
$rows = Type\vec(Type\shape([
'id' => Type\int(),
'name' => Type\string(),
]))->coerce($this->rows);
foreach ($rows as $row) { /* $row is typed */ }
}
Config binding in a service provider. Parse config once and bind a typed config object into the container, so the rest of the app never sees the raw config() array.
$this->app->singleton(StripeConfig::class, fn () =>
StripeConfig::fromArray(config('services.stripe'))
);
Compose rather than branch. Type\union(), Type\nullable(), Type\optional() and Type\dict() replace chains of try/catch or nested ifs. For enum-like strings, Type\literal_scalar('pending') gives you a checked literal; if your installed PSL version provides a backed-enum spec, prefer it over hand-rolled string unions.
Testing. Treat the spec as part of the public surface: test successful coercion with realistic wire values ("42", JSON-decoded payloads) and test failures with expectException(Type\Exception\CoercionException::class). Because specs are cheap to build and immutable, constructing a fresh one per test is fine.
php-standard-library/php-standard-library release notes. Read the changelog in the main repo before upgrading, and pin the version in composer.json.coerce() vs assert() is the main footgun. HTTP and queue payloads are almost always the wrong scalar type ("1", "true"), so assert() will throw where coerce() succeeds. Use coerce() at the boundary, assert() only for data you produced yourself.Type\string() will happily pass any string; it does not validate email format, URL shape, or length. Keep Laravel's email, url, max rules for that and use the type spec as an additional layer.ConvertEmptyStringsToNull turns empty fields into null. After that middleware, Type\string() throws on a submitted-but-empty field. Wrap with Type\nullable(Type\string()) or use Type\optional() inside a shape when the key may be absent.optional() only makes sense inside shape(). Using it as a standalone spec is a misuse; it is a key marker, not a nullable value spec. Use nullable() for values.$data around wholesale.vec() is a list, dict() is a keyed map. A JSON object decoded with json_decode($json, true) is a dict; a JSON array is a vec. vec() requires keys 0..n-1, so a sparse or re-keyed array will fail even though it "looks like an array".Psl\Type\Exception\CoercionException and AssertException (or their common parent), then convert to ValidationException::withMessages(). Without this, a malformed payload becomes a 500 instead of a 422. If your version exposes a path on the exception, use it to map failures to field names.shape() from the runtime spec. Add @return array{name: string, age: int, tags: list<string>} on the spec-adjacent factory so the typed shape survives into analysis.static local or a container-bound spec is enough.$casts is the right tool for attributes coming out of the database. This package is for unstructured input crossing a boundary (HTTP, queue, CLI, third-party API responses).App\Support\Type adapter around the handful of specs and exception conversions you use, so a future swap to webmozart/assert or hand-rolled checks is a one-file change.use function Psl\Type\{shape, string, int, vec, optional}; reads better than Type\int() everywhere, but make sure the team convention is consistent — mixed styles make it hard to tell PSL functions from local helpers.Type\TypeInterface for domain primitives (money, country code) and compose them with shape/union. Give toString() a useful implementation, because it appears in coercion error messages.PHP_INT_MAX or decoded as floats can make integer coercion lossy. Validate ranges with Laravel rules in addition to the type spec when the field is an identifier or amount.How can I help you explore Laravel packages today?