sebastian/type
A collection of value objects that model the PHP type system, including simple, union, intersection, and special types. Use it to inspect, compare, and represent type information programmatically in PHP projects.
Check whether you already have it. In almost every Laravel project sebastian/type arrives transitively via PHPUnit. Before adding it explicitly:
composer show sebastian/type # shows version + "required by phpunit/phpunit"
If it's already there, just use it — but add an explicit require-dev entry only if you're calling its API directly, so an accidental PHPUnit downgrade can't silently change your type semantics.
Install with the right constraint for your PHP version:
php -v # 8.4+ ?
composer require --dev sebastian/type:^7.0 # PHP 8.4+
composer require --dev sebastian/type:^6.0 # PHP 8.3 (Laravel 10/11 on 8.3)
v7.0.0 dropped PHP 8.3; v6.0.0 dropped PHP 8.2. Both 6.0.4 and 7.0.1 contain the class-alias assignability fix — pin at least those if you touch aliased type hints.
Where to look first, in order:
SebastianBergmann\Type\Type — the abstract base: isAssignableFrom(), isSatisfiedBy(), allowsNull(), asString(), plus the is*() predicates.SebastianBergmann\Type\ReflectionMapper — the bridge from PHP reflection to these value objects.SimpleType, ObjectType, UnionType, IntersectionType, GenericObjectType, UnknownType — the concrete shapes you'll branch on.First use case — dump a controller signature:
use SebastianBergmann\Type\ReflectionMapper;
$type = (new ReflectionMapper)->fromReflectionReturnType(
new ReflectionMethod(App\Http\Controllers\UserController::class, 'show')
);
echo $type->asString(); // "App\Models\User|Illuminate\Http\JsonResponse"
For a property (handy with typed Eloquent attributes and promoted constructor properties):
$type = (new ReflectionMapper)->fromPropertyType(
new ReflectionProperty(App\Models\User::class, 'email')
);
That's the whole on-ramp. Everything else is composition of these two calls.
Walk public methods once, map each parameter and return type, feed a schema builder:
final class SignatureScanner
{
public function __construct(private ReflectionMapper $mapper = new ReflectionMapper) {}
public function scan(string $class): array
{
$out = [];
foreach ((new ReflectionClass($class))->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->isConstructor() || $method->isStatic()) {
continue;
}
$out[$method->getName()] = [
'params' => array_map(
fn (ReflectionParameter $p) => $p->getType()
? $this->mapper->fromType($p->getType())->asString()
: 'mixed',
$method->getParameters()
),
'returns' => $method->getReturnType()
? $this->mapper->fromReflectionReturnType($method)->asString()
: 'mixed',
];
}
return $out;
}
}
Because these are value objects, the output is deterministic — safe to snapshot into a generated file and diff in CI.
Enforce invariants your team argues about in review instead of by hand:
use SebastianBergmann\Type\{ObjectType, UnionType, ReflectionMapper};
$actual = (new ReflectionMapper)->fromReflectionReturnType(
new ReflectionMethod(App\Http\Controllers\OrderController::class, 'store')
);
$allowed = UnionType::fromTypes([
new ObjectType(Illuminate\Http\JsonResponse::class),
new ObjectType(Illuminate\Http\RedirectResponse::class),
new ObjectType(Illuminate\Http\Response::class),
]);
$this->assertTrue(
$allowed->isAssignableFrom($actual),
"store() must return an HTTP response, got {$actual->asString()}"
);
Read isAssignableFrom as: "can a value of the argument be assigned to something declared as $this?" — $expected->isAssignableFrom($actual).
Verify every constructor dependency is either concrete or has a matching binding:
foreach ((new ReflectionClass($service))->getConstructor()?->getParameters() ?? [] as $param) {
$type = $param->getType();
if ($type === null) continue;
$declared = (new ReflectionMapper)->fromType($type);
$bound = collect($bindings)->first(
fn (string $abstract) => $declared->isAssignableFrom(new ObjectType($abstract))
);
if ($bound === null && ! $declared->isSatisfiedBy(resolve($param->getName()))) {
report(new RuntimeException("Unresolvable: {$service}::\${$param->getName()}"));
}
}
This catches the DateTimeImmutable vs DateTime class-alias class of bugs at boot instead of in production.
Use isSatisfiedBy() as a post-cast guard, not a raw-input guard:
use SebastianBergmann\Type\{SimpleType, ObjectType};
Rule::make(function (string $attribute, mixed $value, Closure $fail) {
$type = new SimpleType(SimpleType::TYPE_INT);
if (! $type->isSatisfiedBy($value)) {
$fail("The {$attribute} field must be an integer.");
}
});
isSatisfiedBy() is strict — no juggling. '42' does not satisfy int. That's a feature for validating already-cast DTOs, and a trap for FormRequest input.
Reflection is the expensive part, not the mapper. Centralize with a per-request keyed cache:
final class TypeMap
{
private array $cache = [];
public function __construct(private ReflectionMapper $mapper = new ReflectionMapper) {}
public function method(string $class, string $method): SebastianBergmann\Type\Type
{
return $this->cache["{$class}::{$method}"] ??= $this->mapper
->fromReflectionReturnType(new ReflectionMethod($class, $method));
}
}
Bind this as a singleton and inject it — you now have one seam to swap if you later move to PHPStan's own type model.
Branch once on the concrete class, then recurse:
function toSchema(SebastianBergmann\Type\Type $type): array
{
return match (true) {
$type instanceof UnionType => [
'anyOf' => array_map('toSchema', $type->types()),
],
$type instanceof IntersectionType => [
'allOf' => array_map('toSchema', $type->types()),
],
$type instanceof ObjectType => [
'type' => 'object',
'class' => $type->className(),
'nullable' => $type->allowsNull(),
],
default => [
'type' => $type->name(),
'nullable' => $type->allowsNull(),
],
};
}
$expected = new ObjectType(Illuminate\Contracts\Cache\Repository::class);
$this->assertTrue($expected->isAssignableFrom(new ObjectType(get_class($fakeRepository))));
Cheaper and clearer than assertInstanceOf when the declared type is a union or an interface hierarchy.
isAssignableFrom() argument direction is the #1 mistake. SimpleType('iterable')->isAssignableFrom(SimpleType('array')) is true; the reverse is false. Test it with a literal true/false assertion once and never think about it again.
Nullable is not a union. PHP reflection collapses ?int and int|null into a single named type with allowsNull() === true. So UnionType::fromTypes() is something you'll construct manually for your own models; it rarely shows up from ReflectionMapper. Don't write if ($type instanceof UnionType) and assume it caught nullability.
ReflectionMapper throws on anything it can't model. Unsupported/odd named types raise SebastianBergmann\Type\RuntimeException. Wrap codegen loops:
try {
$type = $mapper->fromType($reflectionType);
} catch (SebastianBergmann\Type\RuntimeException) {
$type = new UnknownType($reflectionType->getName());
}
One unmodelled self/parent reference in a legacy class should not take down your whole docs build.
iterable is modelled as a SimpleType, not array|Traversable. This was the subject of the iterable-in-unions fixes (6.0.1/6.0.2/5.1.1/5.1.2). If your code branches on array or Traversable and you feed it iterable, you'll fall through to the default arm. Handle it explicitly.
void and never have no satisfying value. isSatisfiedBy() is meaningless for them. Branch on $type->isVoid() / $type->isNever() before doing anything value-shaped, or your generators will emit nonsense schemas.
Object types resolve classes by name, which triggers autoloading. ObjectType::className() gives you a string; isSatisfiedBy() instantiates instanceof against it. If you're scanning classes that aren't loadable in the current process (separate package, generated code), guard with class_exists() first or you'll get confusing false negatives.
Pin ^6.0.4 / ^7.0.1 minimum if you use class aliases. Earlier versions returned incorrect isAssignableFrom() results for use DateTime as DT style aliases — a silent, data-dependent bug. There is no runtime error to warn you.
Don't use it for raw HTTP input validation. JSON gives you strings; these types are strict. Cast with a FormRequest or a DTO constructor first, then validate.
Keep the composer constraint loose. Because PHPUnit depends on this package, a hard pin can cause dependency conflicts on composer update. Use ^7.0, not 7.0.1.
Prefer composition over extending Type. The abstract class is small and the constructors are not a stable extension point across majors (v5 → v6 → v7 each dropped PHP versions and moved internals). Wrap it in your own DomainType/normalizer class instead of subclassing, and you won't be rewriting at the next major.
name() vs value() vs asString() differ in nullability rendering. Pick one for anything you snapshot or generate, otherwise a version bump will produce a noisy diff across hundreds of generated files.
Debugging in Tinker: dump($type) prints nested value objects legibly; echo $type->asString() gives the source-like form. Reach for asString() first when a type assertion fails — the message tells you whether you got a union, a nullable, or an UnknownType fallback.
It is not a static analyzer. This models types; it does not infer them. If you need flow analysis, use PHPStan/Psalm and consume this package through their rule APIs rather than reimplementing inference.
How can I help you explore Laravel packages today?