yorcreative / argonaut-dto
A framework-agnostic PHP DTO package that provides nested casting, recursive serialization, validation, assemblers, and immutable DTO support out of the box.
Fund package maintenance!
Requires
- php: ^8.3
- yorcreative/data-validation: ^1.0
Requires (Dev)
- laravel/pint: ^1.30
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-17 08:08:42 UTC
README
Argonaut DTO
Framework-agnostic Data Transfer Objects for PHP 8.3+. Argonaut DTO provides the useful parts of Laravel Argonaut DTO without requiring Laravel: nested DTO and enum casting, recursive serialization, mutable and immutable DTOs, convention-based assemblers, and lightweight validation.
Requirements
- PHP 8.3, 8.4, or 8.5
Installation
Install via Composer:
composer require yorcreative/argonaut-dto
Basic DTO
use YorCreative\ArgonautDTO\ArgonautDTO; final class UserDTO extends ArgonautDTO { public string $name; public string $email; protected array $casts = [ 'name' => 'string', 'email' => 'string', ]; public function rules(): array { return [ 'name' => ['required', 'string'], 'email' => ['required', 'email'], ]; } } $user = new UserDTO(['name' => 'Jane', 'email' => 'jane@example.com']); $user->merge(['name' => 'Jane Doe']); $user->toArray(); $user->toJson(); $user->isValid();
Unknown input keys are ignored. A setter named set<FieldName> takes precedence over direct property assignment. Declare $prioritizedAttributes when setters must run before the remaining input attributes.
Named constructors
$profile = ProfileDTO::fromArray(['fullName' => 'Ada Lovelace']); $profile = ProfileDTO::fromJson('{"fullName":"Ada Lovelace"}'); $tags = TagDTO::collection([['name' => 'a'], ['name' => 'b']]); // Collection<TagDTO>
fromArray() and fromJson() are new in 1.1.0. collection() is not new — it
has existed since 1.0.0 and is listed here because 1.1.0 makes its return type
generic, so static analysis now narrows the elements.
fromJson() throws JsonException on malformed JSON. It also throws
JsonException when the JSON is valid but does not decode to an object — for
example 'null', '"a string"', '123', or a JSON array such as
'[{"fullName":"Ada"}]', '[1,2]' or '[]' — with a message naming the type
it found instead, such as ProfileDTO::fromJson() expects a JSON object, null given. The root type is read from the document itself rather than inferred
from the decoded value, because associative decoding erases the difference: an
empty array and an empty object both decode to [], and a JSON object with
numeric keys ('{"0":"a"}') decodes to a PHP list. So '[]' is rejected and
'{"0":"a"}' is accepted. All three named constructors are available on both
ArgonautDTO and ArgonautImmutableDTO.
Nested casts
use YorCreative\ArgonautDTO\ArgonautDTO; use YorCreative\ArgonautDTO\Collection; final class OrderDTO extends ArgonautDTO { public array $items; public Collection $history; public ?UserDTO $customer = null; protected array $casts = [ 'items' => [OrderItemDTO::class], 'history' => Collection::class.':'.OrderEventDTO::class, 'customer' => UserDTO::class, ]; }
Array casts use [SomeDTO::class]. Collection casts use Collection::class . ':' . SomeDTO::class (the shorthand collection:SomeDTO is also supported). Arrays, traversables, and the package Collection can be used as input.
Backed enums and DateTimeInterface implementations can be cast directly:
protected array $casts = [ 'status' => OrderStatus::class, 'createdAt' => DateTimeImmutable::class, ];
Nested DTOs and backed enums serialize recursively; enums serialize to their backing values.
Attribute casting
Casts can be declared with attributes instead of the $casts array:
use YorCreative\ArgonautDTO\ArgonautDTO; use YorCreative\ArgonautDTO\Attributes\CastCollection; use YorCreative\ArgonautDTO\Attributes\CastEnum; use YorCreative\ArgonautDTO\Attributes\CastTo; use YorCreative\ArgonautDTO\Collection; class OrderDTO extends ArgonautDTO { #[CastTo(CustomerDTO::class)] public ?CustomerDTO $customer = null; /** @var array<int, LineDTO> */ #[CastTo(LineDTO::class, many: true)] public array $lines = []; /** @var Collection<LineDTO> */ #[CastCollection(LineDTO::class)] public ?Collection $lineCollection = null; #[CastEnum(Status::class)] public ?Status $status = null; }
Each attribute is equivalent to the $casts entry it replaces:
| Attribute | Equivalent $casts value |
|---|---|
#[CastTo(LineDTO::class)] |
LineDTO::class |
#[CastTo(LineDTO::class, many: true)] |
[LineDTO::class] |
#[CastCollection(LineDTO::class)] |
'collection:'.LineDTO::class |
#[CastEnum(Status::class)] |
Status::class |
Attributes and the $casts array can coexist. When both describe the same
property, the $casts array wins. Attributes are fixed at the point of
property declaration, so a subclass cannot change the attribute on a property it
inherits without redeclaring that property — $casts is its lever short of
redeclaration, and this rule keeps that override working:
class ParentDTO extends ArgonautDTO { #[CastTo(TagDTO::class)] public mixed $thing = null; } class ChildDTO extends ParentDTO { // Overrides the inherited attribute. protected array $casts = ['thing' => 'string']; }
Like $casts, cast attributes only apply on the direct property-assignment
path. If the DTO declares a set<Property>() setter for that property, the
setter runs instead and is responsible for its own conversion — the attribute
is silently never consulted.
Custom cast classes
For a conversion the library doesn't know how to do — a value object, a
domain-specific formatter — implement CastsArgonautAttribute:
interface CastsArgonautAttribute { public function get(string $key, mixed $value): mixed; }
Declare it the same way as any other cast — as a $casts entry or as a
#[CastWith] attribute:
use YorCreative\ArgonautDTO\ArgonautDTO; use YorCreative\ArgonautDTO\Attributes\CastWith; use YorCreative\ArgonautDTO\CastsArgonautAttribute; class MoneyCast implements CastsArgonautAttribute { public function get(string $key, mixed $value): mixed { return sprintf('$%0.2f', $value / 100); } } class OrderDTO extends ArgonautDTO { protected array $casts = ['price' => MoneyCast::class]; public mixed $price = null; } class InvoiceDTO extends ArgonautDTO { #[CastWith(MoneyCast::class)] public mixed $total = null; } (new OrderDTO(['price' => 1999]))->price; // '$19.99' (new InvoiceDTO(['total' => 4250]))->total; // '$42.50'
A custom cast works with all three cast container forms used elsewhere in this library, with either declaration:
$casts value |
#[CastWith] equivalent |
Behavior |
|---|---|---|
MoneyCast::class |
#[CastWith(MoneyCast::class)] |
Applied to the value once |
[MoneyCast::class] |
#[CastWith(MoneyCast::class, many: true)] |
Value is iterated as an array; applied to each item |
'collection:'.MoneyCast::class |
#[CastWith('collection:'.MoneyCast::class)] |
Value is iterated as a Collection; applied to each item, result is a Collection |
class OrderDTO extends ArgonautDTO { protected array $casts = [ 'price' => MoneyCast::class, 'lineTotals' => [MoneyCast::class], 'refunds' => 'collection:'.MoneyCast::class, ]; public mixed $price = null; /** @var array<int, mixed> */ public array $lineTotals = []; /** @var Collection<mixed> */ public ?Collection $refunds = null; } $order = new OrderDTO([ 'price' => 1999, 'lineTotals' => [500, 750], 'refunds' => [100], ]); $order->price; // '$19.99' $order->lineTotals; // ['$5.00', '$7.50'] $order->refunds->all(); // ['$1.00']
The same three forms via #[CastWith]:
class InvoiceDTO extends ArgonautDTO { #[CastWith(MoneyCast::class)] public mixed $total = null; /** @var array<int, mixed> */ #[CastWith(MoneyCast::class, many: true)] public array $lineTotals = []; /** @var Collection<mixed> */ #[CastWith('collection:'.MoneyCast::class)] public ?Collection $refunds = null; } $invoice = new InvoiceDTO([ 'total' => 4250, 'lineTotals' => [500, 750], 'refunds' => [100], ]); $invoice->total; // '$42.50' $invoice->lineTotals; // ['$5.00', '$7.50'] $invoice->refunds->all(); // ['$1.00']
Where a property has both a $casts entry and a #[CastWith] attribute,
$casts wins — the same precedence rule as the other cast attributes:
class ConflictingDTO extends ArgonautDTO { protected array $casts = ['name' => 'string']; // wins over the attribute below #[CastWith(UppercaseCast::class)] public mixed $name = null; } (new ConflictingDTO(['name' => 'ada']))->name; // 'ada', not 'ADA'
A custom cast never receives null — setAttribute() short-circuits null
before casting reaches it, so implementations don't need a null check.
Implementations must be stateless and constructible with no arguments.
One instance is created per cast class and reused across every DTO that uses
it — a cast that keeps state between calls will leak it, and a cast with a
required constructor parameter raises ArgumentCountError.
Custom casts work identically on ArgonautDTO and ArgonautImmutableDTO:
final class InvoiceSnapshotDTO extends ArgonautImmutableDTO { #[CastWith(MoneyCast::class)] public readonly mixed $total; } (new InvoiceSnapshotDTO(['total' => 4250]))->total; // '$42.50'
Key mapping
Incoming keys can be renamed to property names before anything else runs,
either with a $maps array or with a #[MapFrom] attribute on the property:
use YorCreative\ArgonautDTO\ArgonautDTO; use YorCreative\ArgonautDTO\Attributes\MapFrom; final class ProfileDTO extends ArgonautDTO { protected array $maps = ['first_name' => 'firstName']; public string $firstName = ''; #[MapFrom('last_name')] public string $lastName = ''; } $profile = new ProfileDTO(['first_name' => 'Ada', 'last_name' => 'Lovelace']); $profile->firstName; // 'Ada' $profile->lastName; // 'Lovelace'
Both forms can be used on the same class. Where both describe the same
target property, $maps wins and the #[MapFrom] attribute for that
property is dropped entirely — even when the two forms name different
incoming keys:
final class ConflictingDTO extends ArgonautDTO { // $maps wins: the #[MapFrom] attribute on $name below is dropped // entirely, even though 'legacy_name' and 'name_field' are different // incoming keys. protected array $maps = ['legacy_name' => 'name']; #[MapFrom('name_field')] public string $name = ''; } $dto = new ConflictingDTO(['legacy_name' => 'Ada', 'name_field' => 'ignored']); $dto->name; // 'Ada' — 'name_field' is no longer a recognized mapping, so it // is just an unknown input key and is ignored.
Key mapping runs before anything else that processes input — before the
$prioritizedAttributes pass and before casting. That applies to every input
path that takes incoming keys: the constructor, setAttributes(), merge(),
with(), and the single-key setMappedAttribute(). setAttribute() is the
assignment seam underneath them and takes property names only — it does not
map, so an override sees each assignment once, under the name it declared, and
may safely reach for other attributes from inside it (use
setMappedAttribute() there if you want to name one by its alias). A $casts
entry (or cast attribute) for a mapped property is
therefore keyed by the property name, not the incoming key:
final class AmountDTO extends ArgonautDTO { #[MapFrom('unit_price')] public int $price = 0; // Casting looks up 'price' — the property name — not 'unit_price', the // incoming key, because mapping already ran and renamed the key. protected array $casts = ['price' => 'integer']; } $amount = new AmountDTO(['unit_price' => '4200']); $amount->price; // 4200 (int)
If both a mapped key and its target property name appear in the same input array, whichever occurs later in the array wins.
with() on ArgonautImmutableDTO does not rely on that rule. It maps the
incoming keys to work out which properties are changing, copies every
unchanged property to the new instance verbatim, and routes only the given
attributes through the normal input path. Unchanged values are never re-cast:
a full-state rebuild would run the casting engine over already-cast values,
and while a built-in cast survives that on its identity guard, a custom cast
is a transformation and would apply twice. Mapped keys work in with() for
the same reason they work anywhere else — they are mapped on the way in:
use YorCreative\ArgonautDTO\ArgonautImmutableDTO; final class ContactDTO extends ArgonautImmutableDTO { protected array $maps = ['email_address' => 'email']; public readonly string $email; } $contact = new ContactDTO(['email_address' => 'ada@example.com']); $contact->email; // 'ada@example.com' $updated = $contact->with(['email_address' => 'lovelace@example.com']); $updated->email; // 'lovelace@example.com'
Two output methods reverse the mapping: toMappedArray(?int $depth = null)
and toMappedJson(int $options = 0, ?int $depth = null) rename each top-level
key back to its incoming key. toArray() and toJson() are unchanged —
they still emit property names:
final class PersonDTO extends ArgonautDTO { protected array $maps = ['first_name' => 'firstName']; public string $firstName = ''; } $person = new PersonDTO(['first_name' => 'Grace']); $person->toArray(); // ['firstName' => 'Grace'] $person->toJson(); // '{"firstName":"Grace"}' $person->toMappedArray(); // ['first_name' => 'Grace'] $person->toMappedJson(); // '{"first_name":"Grace"}'
toMappedJson() reports encoding failures exactly as toJson() does — both
delegate to the same internal encoder.
Renaming onto an existing property throws. If a mapping renames one
property onto the name of another property that also serializes, both land on
the same output key and one value would be lost. toMappedArray() and
toMappedJson() raise LogicException instead of dropping it:
final class AmbiguousDTO extends ArgonautDTO { // fullName is renamed to 'name' on output, but 'name' is a property too. protected array $maps = ['name' => 'fullName']; public ?string $fullName = null; public ?string $name = null; } (new AmbiguousDTO(['fullName' => 'Ada']))->toMappedArray(); // LogicException: two keys collide on 'name'
toArray() is unaffected — it emits property names, which are unique by
construction.
Two properties cannot claim the same incoming key. Declaring
#[MapFrom('key')] on more than one property is ambiguous — only one could
receive the value, and which one would depend on reflection order — so it
throws LogicException when the map is first built.
Key mapping is top-level only. toMappedArray() renames this DTO's own
keys; it does not reach into nested DTOs and rename their keys too, even if
the nested class declares its own $maps or #[MapFrom]:
final class AddressDTO extends ArgonautDTO { protected array $maps = ['zip_code' => 'zip']; public string $zip = ''; } final class CustomerDTO extends ArgonautDTO { protected array $casts = ['address' => AddressDTO::class]; public ?AddressDTO $address = null; } $customer = new CustomerDTO(['address' => ['zip_code' => '10001']]); $customer->toArray(); // ['address' => ['zip' => '10001']] $customer->toMappedArray(); // ['address' => ['zip' => '10001']] — identical. toMappedArray() only // renames CustomerDTO's own top-level keys ('address' has no mapping here, // so it keeps its name). By the time it runs, toArray() has already // flattened $address into a plain array — AddressDTO's own $maps has no // object left to apply to.
This is inherent to how toArray() is used: it flattens the whole DTO graph
into plain arrays before toMappedArray() ever sees it, and toArray()
itself cannot be changed to preserve nested objects instead, because it is
declared on ArgonautDTOContract, which consumers implement directly. A
consumer with nested DTOs should expect only the outermost keys to be
renamed.
Serialization depth
toArray(), toJson(), and jsonSerialize() walk the whole DTO graph:
$dto->toArray(); // full graph $dto->toArray(2); // two levels of nested DTOs $dto->toJson(depth: 2); // same limit, JSON encoded
$depth counts DTO nesting levels and defaults to ArgonautDTO::DEFAULT_MAX_DEPTH (512). Exceeding
it throws a RuntimeException rather than silently emitting an empty array, so truncation can never
be mistaken for missing data.
Circular references are detected directly, not inferred from the depth limit, and raise
YorCreative\ArgonautDTO\CircularReferenceException (a RuntimeException) naming the instance
involved. The same DTO appearing in two sibling branches is a shared reference rather than a cycle
and serializes normally.
toJson() also accounts for PHP's own json_encode() depth limit. Because each array or collection
of DTOs adds a level of its own, a graph well inside $depth can still exceed the encoder's 512
levels; toJson() raises the encoder limit to fit whatever the walk produced. Note that
json_decode() has the same 512 default, so decoding very deep payloads needs an explicit depth.
Collection
Collection is a small, dependency-free collection returned by
collection: casts and by collection(). It implements ArrayAccess,
Countable, IteratorAggregate and JsonSerializable.
Using a different collection
collection: casts produce whatever class collectionClass() returns, so a
DTO can use a framework's collection, or your own, instead:
use Illuminate\Support\Collection; final class OrderDTO extends ArgonautDTO { protected array $casts = ['lines' => 'collection:'.LineDTO::class]; /** @var Collection<int, LineDTO> */ public Collection $lines; protected function collectionClass(): string { return Collection::class; } }
The cast directive may name that collection too — all three of these are recognised:
'lines' => Collection::class.':'.LineDTO::class, // the class you configured 'lines' => \YorCreative\ArgonautDTO\Collection::class.':'.LineDTO::class, 'lines' => 'collection:'.LineDTO::class, // shorthand
The package gains no dependency from this — it instantiates the class string you return and nothing more. The contract is:
| Requirement | Why |
|---|---|
__construct(array $items) |
how the cast builds it |
map(callable): self |
applied per item by collection:<DTO> casts |
Traversable |
so toArray() can walk it back into a nested array |
Both are validated when the collection is built, so a class missing either is
reported by name rather than failing later inside the casting engine. The
checks run outside newCollection(), so overriding that factory does not skip
them.
A recognised collection is iterated on output rather than read through
all(), so a subclass overriding getIterator() decides what is published.
Only a collection the DTO recognises is walked on output — this package's own,
or the one collectionClass() returns. An object that merely happens to be
iterable is returned as it is, so json_encode() still calls its
jsonSerialize(), a generator is not consumed by being serialized, and an
object with an unrelated iterator is still read as a property bag.
Illuminate\Support\Collection satisfies all three. Override
newCollection(array $items) instead if construction needs more than
new $class($items).
collection() is unaffected and always returns this package's Collection.
It is a static factory, so it has no instance to ask, and collectionClass()
is deliberately an instance method so two DTOs of different classes can differ.
Build the collection yourself if you need another type there.
It is generic over its value type, so static analysis narrows elements:
/** @var Collection<TagDTO> */ public Collection $tags; $this->tags->first()->name; // PHPStan resolves this to TagDTO::$name
Available methods:
| Method | Returns | Notes |
|---|---|---|
all() |
array |
Underlying items, keys preserved |
map(callable) |
Collection |
Preserves keys |
filter(?callable) |
Collection |
Callback receives value and key |
first(mixed $default = null) |
TValue|null |
|
last(mixed $default = null) |
TValue|null |
|
reduce(callable, mixed $initial = null) |
mixed |
Callback receives carry, value, key |
contains(mixed) |
bool |
A value compared strictly, or a predicate |
pluck(string $value, ?string $key = null) |
Collection |
Reads array keys, ArrayAccess offsets, or object properties |
groupBy(callable) |
Collection |
A Collection of Collections |
keyBy(callable) |
Collection |
Later duplicates win |
values() |
Collection |
Reindexes |
isEmpty() / isNotEmpty() |
bool |
|
count() |
int |
|
validateAll(bool $throw = true) |
true|array |
Validates every item; errors keyed by collection key |
isValidAll() |
bool |
True when every item validates |
Validating a collection of DTOs
$emails = EmailDTO::collection([ ['email' => 'ada@example.com'], ['email' => 'not-an-email'], ]); $emails->isValidAll(); // false $errors = $emails->validateAll(false); // [1 => ['email' => ['The email must be a valid email address.']]] $emails->validateAll(); // throws the failing item's ValidationException
Errors are keyed by the item's collection key, so string-keyed collections
report which item failed. With $throw = true the first failing item's own
ValidationException is raised — use validateAll(false) when you need to know
which index failed.
An item that is not an Argonaut DTO, or a DTO whose class declares no rules(),
is a programming error and raises LogicException rather than being reported as
invalid.
These mirror the names and common calling conventions of
Illuminate\Support\Collection so the API is familiar, but deliberately omit
Laravel's operator overloads — contains() takes a value or a predicate, not
($key, $operator, $value).
One caveat worth knowing: contains() decides between "value" and "predicate"
by testing is_callable(), with one deliberate exception — a string is
always a value, never a predicate, so a collection of strings stays
searchable for one that happens to name a function (contains('is_int') looks
for the string). Every other callable form is invoked as a predicate: a
closure, [$object, 'method'], or an object with __invoke(). Predicates
receive ($item, $key), so a one-argument function such as is_int(...) is
not a valid predicate. A collection whose items are themselves closures
cannot be searched by value; Illuminate\Support\Collection has the same
limitation. Use in_array($needle, $collection->all(), true) if you need that.
pluck() reads array keys, ArrayAccess offsets, and public object
properties. A key that is simply absent yields null, as it does for arrays.
A property that exists but is not public throws LogicException rather than
yielding null, so a private field is never silently reported as empty and a
typo is never mistaken for one.
Immutable DTOs
Declare DTO properties as readonly and extend ArgonautImmutableDTO:
final class UserSnapshotDTO extends ArgonautImmutableDTO { public readonly string $id; public readonly string $name; }
Readonly properties are initialized once during construction. Missing required properties remain uninitialized, allowing PHP's normal typed-property error to identify an incomplete snapshot.
Copying with changes
with() returns a copy with the given attributes applied, leaving the original
untouched. It is available on both base classes.
$original = new UserDTO(['firstName' => 'Jane', 'lastName' => 'Doe']); $updated = $original->with(['lastName' => 'Smith']); $updated->lastName; // 'Smith' $original->lastName; // 'Doe' — unchanged
Only the attributes you pass are re-applied, so setter-derived properties recompute from the new values rather than being overwritten with stale ones:
class UserDTO extends ArgonautDTO { public string $firstName = ''; public string $lastName = ''; public string $fullName = ''; /** @var list<string> */ protected array $prioritizedAttributes = ['firstName', 'lastName']; public function setFirstName(string $value): static { $this->firstName = $value; $this->fullName = trim($value.' '.$this->lastName); return $this; } public function setLastName(string $value): static { $this->lastName = $value; $this->fullName = trim($this->firstName.' '.$value); return $this; } } $original = new UserDTO(['firstName' => 'Jane', 'lastName' => 'Doe']); $original->with(['lastName' => 'Smith'])->fullName; // 'Jane Smith'
with() is a shallow copy — nested DTOs and other objects are shared with
the original, not duplicated. Because ArgonautDTO is mutable, that means
mutating a nested DTO reached through the copy also mutates the original:
class OrderDTO extends ArgonautDTO { public ?TagDTO $tag = null; /** @var array<string, string> */ protected array $casts = ['tag' => TagDTO::class]; } $original = new OrderDTO(['tag' => ['name' => 'draft']]); $copy = $original->with([]); $copy->tag->name = 'final'; $original->tag->name; // 'final' — the nested DTO is shared, not copied
Rebuild nested values explicitly if you need them independent.
Assemblers
Assemblers resolve to<ClassName> first, then from<ClassName>:
final class UserAssembler extends ArgonautAssembler { public static function toUserDTO(object $input): UserDTO { return new UserDTO([ 'name' => $input->display_name, 'email' => $input->email, ]); } } $user = UserAssembler::assemble($payload, UserDTO::class); $users = UserAssembler::fromArray($payloads, UserDTO::class);
fromArray() and fromCollection() return the package's dependency-free Collection, which supports iteration, array access, count(), first(), all(), map(), and filter().
Instance assembler methods are supported through assembleInstance() or by passing an assembler instance to assemble().
Validation
rules() uses YorCreative DataValidation, including its complete rule set, nested paths, wildcards, and custom closures. Argonaut preserves the convenient int, bool, collection, and sometimes aliases: collections are arrays after serialization, and sometimes omits the rule set when the field is absent.
Custom closures use DataValidation's signature: function (string $field, mixed $value, callable $fail, array $data): bool.
$errors = $user->validate(throw: false); // ['email' => ['The email field failed the email rule.']]
Calling validate() without throw: false raises YorCreative\ArgonautDTO\ValidationException.
isValid() returns false only for validation failure. A missing or broken rules() method is a
programming error and surfaces as an exception rather than being reported as invalid data. Pass
isValid(throw: true) to raise ValidationException instead of returning false.
Relationship to the Laravel package
yorcreative/argonaut-dto contains no Illuminate dependency. The Laravel package can provide Laravel-specific adapters and continue to expose its existing API while sharing this framework-neutral behavior. Laravel applications that need Laravel's validator or collection implementation can remain on yorcreative/laravel-argonaut-dto.
Testing
Run the test suite:
composer test
Run tests with coverage report:
composer coverage
Run static analysis (PHPStan):
composer phpstan
Run code style fixer (Pint):
composer lint
Continuous integration runs the suite on PHP 8.3, 8.4, and 8.5 against locked, lowest, and highest dependency sets, alongside PHPStan, Pint, Composer validation, GitHub dependency review, and scheduled Composer security audits.
Credits
License
This package is open-sourced software licensed under the MIT license.