phrity/util-transformer
Type transformers, normalizers, and resolvers for PHP. Convert values between types, encode and decode JSON, flatten nested arrays, and stack transformers with recursion and first-match resolvers for flexible data transformation.
composer require phrity/util-transformer
Requires PHP ^8.1. There is no service provider, config file or facade — it's a plain library of small, composable objects. You wire it into Laravel yourself.
Where to look first
docs/converters/BasicType.md — the catch‑all converter; read this to understand the canTransform() contract before anything else.docs/resolvers/FirstMatch.md and docs/resolvers/Recursion.md — these two are what you'll actually use 90% of the time.docs/ is one short page per transformer; skim the list in the README and only open the ones you need (Enum, DateTime, Throwable, JsonDecoder, FlattenDecoder).Core contract
Every transformer and resolver implements the same two methods:
use Phrity\Util\Transformer\Type;
$transformer->canTransform($subject); // bool — can you handle this?
$transformer->canTransform($subject, Type::STRING); // bool — can you produce this type?
$transformer->transform($subject, Type::STRING); // mixed — the actual result
The optional second argument is a target type hint from the Type class. Resolvers implement the same interface, so a resolver is interchangeable with a single converter — that's the whole design.
First use case: one "fold anything to a string/array" service
The highest-value day-one move is a single app-level entry point you inject everywhere, instead of scattering (string), json_encode(), $e->getMessage() and ->format() calls through your codebase.
// app/Services/TransformerFactory.php
namespace App\Services;
use Phrity\Util\Transformer\Converter\{
BasicTypeConverter, DateTimeConverter, EnumConverter,
JsonSerializableConverter, ReadableConverter,
StringableConverter, ThrowableConverter,
};
use Phrity\Util\Transformer\Resolver\{FirstMatchResolver, RecursionResolver};
class TransformerFactory
{
public static function make(): RecursionResolver
{
return new RecursionResolver(
new FirstMatchResolver([
new ThrowableConverter(),
new EnumConverter(),
new DateTimeConverter(),
new StringableConverter(),
new JsonSerializableConverter(),
new ReadableConverter(),
new BasicTypeConverter(), // keep last: it matches everything
])
);
}
}
// app/Providers/AppServiceProvider.php (register() method)
$this->app->singleton(\Phrity\Util\Transformer\TransformerInterface::class, fn () =>
\App\Services\TransformerFactory::make()
);
Check the actual interface name in
src/— the package shipsTransformerInterface, but if a minor version renames it, bind against your ownApp\Contracts\Transformeradapter instead (see Gotchas).
Then, anywhere:
$transformer = app(\Phrity\Util\Transformer\TransformerInterface::class);
$transformer->transform($model->status, \Phrity\Util\Transformer\Type::STRING); // "active"
$transformer->transform($exception, \Phrity\Util\Transformer\Type::ARRAY); // structured context
$transformer->transform($request->all(), \Phrity\Util\Transformer\Type::ARRAY); // recursively normalized
FirstMatchResolver as your "sane default" serializerOrdering is the whole game. Put specific converters first, BasicTypeConverter last, because it will happily claim anything:
new FirstMatchResolver([
new EnumConverter(), // BackedEnum -> value/name
new DateTimeConverter(), // DateTimeInterface -> string
new StringableConverter(), // objects with __toString
new JsonSerializableConverter(),
new ThrowableConverter(),
new ReadableConverter(), // true/false/null -> "true"/"false"/"null"
new BasicTypeConverter(), // final fallback
]);
Wrap that in RecursionResolver and you get "fold this arbitrarily nested thing into a scalar tree" as one call — the single most useful primitive in the package for a Laravel app.
ChainedResolverChainedResolver feeds each transformer's output into the next. This is exactly the shape of an ingestion pipeline:
use Phrity\Util\Transformer\Codec\{JsonDecoder, FlattenDecoder};
use Phrity\Util\Transformer\Resolver\ChainedResolver;
// raw JSON string -> nested array -> flat dotted array (for cache keys / CSV / DB columns)
$pipeline = new ChainedResolver([
new JsonDecoder(),
new FlattenDecoder(),
]);
$flat = $pipeline->transform($rawPayload, Type::ARRAY);
Guard it, since a malformed string will fail inside the chain:
if ($pipeline->canTransform($rawPayload, Type::ARRAY)) {
$flat = $pipeline->transform($rawPayload, Type::ARRAY);
}
toArray()// app/Http/Resources/OrderResource.php
public function toArray($request): array
{
$transformer = app(TransformerInterface::class);
return [
'id' => $this->id,
'status' => $transformer->transform($this->status, Type::STRING),
'placed_at' => $transformer->transform($this->placed_at, Type::STRING),
'meta' => $transformer->transform($this->meta ?? [], Type::ARRAY),
'lines' => $this->lines->map(fn ($line) => [
'sku' => $line->sku,
'price' => $transformer->transform($line->price, Type::STRING),
])->all(),
];
}
Be honest about the trade-off: this is a consistency win, not a brevity win. If a resource is trivially typed already, don't wrap it. Use the transformer where the value could legitimately be an enum, a Carbon instance, a stringable VO, or a Throwable.
Form requests are a great fit because ReversedReadableConverter + StringResolver cover the classic "user sent "yes"/"no"/"null"" normalization problem:
protected function prepareForValidation(): void
{
$t = app(TransformerInterface::class);
$this->merge([
'is_gift' => $t->transform($this->input('is_gift'), Type::BOOL),
'note' => $t->transform($this->input('note'), Type::STRING),
]);
}
StringResolver is the "fold anything to a string" convenience — useful when you need a guaranteed scalar for a log line, cache key or header and don't care about the source type.
// app/Exceptions/Handler.php (or bootstrap/app.php withExceptions)
->withExceptions(function (Exceptions $exceptions) {
$exceptions->context(function (Throwable $e) {
return app(TransformerInterface::class)->transform($e, Type::ARRAY);
});
})
ThrowableConverter gives you class/message/trace without writing your own recursive walker, and it composes with RecursionResolver so nested previous exceptions come out flat-ish rather than as raw objects (which would blow up json_encode in logs).
Keep normalization at the edges, not in controllers:
class NormalizeJsonResponse
{
public function __construct(private TransformerInterface $transformer) {}
public function handle($request, Closure $next)
{
$response = $next($request);
if ($response instanceof JsonResponse && is_array($response->getData(true))) {
$response->setData(
$this->transformer->transform($response->getData(true), Type::ARRAY)
);
}
return $response;
}
}
This is the cheapest way to stop "Unable to JSON encode payload" errors caused by enums or __toString-less VOs leaking into responses.
If you already run symfony/serializer (Laravel pulls parts of Symfony anyway), SymfonyNormalizerWrapper lets a NormalizerInterface participate in a FirstMatchResolver chain:
new FirstMatchResolver([
new SymfonyNormalizerWrapper(new MyApiNormalizer()),
new EnumConverter(),
new BasicTypeConverter(),
]);
Only add this dependency if you actually have Symfony normalizers — otherwise it's dead weight.
Transformers are pure and cheap, so they're perfect dataProvider fodder. Put one assertion helper in tests/:
// tests/Support/TransformsTo.php
public static function assertTransformsTo(mixed $expected, mixed $subject, string $type): void
{
$t = TransformerFactory::make();
self::assertTrue($t->canTransform($subject, $type), 'canTransform returned false');
self::assertSame($expected, $t->transform($subject, $type));
}
Then a data provider covering enum → string, Carbon → string, true → "true", Throwable → array, nested arrays → flat. This is where you'll discover ordering bugs in your FirstMatchResolver before production does.
Anything Laravel-specific (Money VOs, Collection, Carbon with a specific format, your own ValueObject base class) belongs in a small class implementing the same two methods:
namespace App\Support\Transformers;
use Phrity\Util\Transformer\Type;
use Illuminate\Support\Collection;
class CollectionConverter
{
public function canTransform(mixed $subject, ?string $type = null): bool
{
return $subject instanceof Collection
&& in_array($type, [null, Type::ARRAY], true);
}
public function transform(mixed $subject, ?string $type = null): array
{
return $subject->toArray();
}
}
Register it before BasicTypeConverter in the chain. This is where most of the package's real value shows up — it gives you a naming convention and a resolution strategy for a problem every Laravel codebase otherwise solves ad hoc.
FlattenDecoder is genuinely useful for two Laravel things beyond JSON: deterministic cache keys from nested arrays, and stuffing nested filter/settings trees into a single indexed column.
$key = 'filters:' . md5(json_encode((new FlattenDecoder())->transform($filters, Type::ARRAY)));
canTransform() is a promise, not a proof. All the documented usage is if (canTransform(...)) { transform(...) }. A canTransform() returning true does not guarantee transform() won't throw — converters like JsonDecoder and ReversedReadableConverter are inherently best-effort. Wrap risky chains in try/catch or a rescue() helper; don't let a malformed payload 500 an endpoint.
Ordering in FirstMatchResolver is load-bearing. BasicTypeConverter claims everything, including objects (it will coerce/cast where it can). Put it last, always. The same goes for StringableConverter — it must precede BasicTypeConverter, or you'll get surprising casts instead of __toString().
Pass the target type explicitly inside resolvers. transform($subject) with no second argument makes behavior depend on whatever default each converter picks. In a chain, always pass Type::STRING / Type::ARRAY / etc. so each stage is deterministic and canTransform() checks actually match the call you make.
BasicTypeConverter will happily produce lossy results. DateTime → int or an object → bool are technically supported and almost never what you want. Treat Type::* as your own contract: only route types you intend to support, and let the chain fail otherwise.
Cycles will hang RecursionResolver. Bidirectional Eloquent relations, or a Throwable whose previous chain loops, will recurse until memory or depth dies. Keep a depth counter in a decorating transformer if there's any chance of cycles in your input, and prefer passing ->toArray() of a model over the model itself.
DateTimeConverter and timezones. A Carbon instance is a DateTimeInterface, so it's matched — but the output string format and timezone come from the library's defaults, not config('app.timezone'). If your API contract pins ISO8601/UTC, write a one-line custom converter that calls ->toIso8601String() and put it before DateTimeConverter, rather than post-processing.
Enums: backed vs pure. Backed enums map to ->value; pure enums have no value, so the string form is the case name. If your API contract uses ->name for backed enums too, the default will surprise you — override with a custom converter. Also remember EnumConverter outputs a scalar: if you need the case-insensitive resolution direction (string → enum), that's Enum::tryFrom() in your own code, not this package.
ReadableConverter / ReversedReadableConverter are opinionated and asymmetric. true → "true", null → "null" going out; "yes"/"no"/"on"/"off"/"1"/"0" coming back in. Which strings are accepted is fixed by the library, not configurable. Don't build user-facing boolean parsing on it without a data-provider test on your exact accepted set; and don't assume round-tripping (transform(transform(true, STRING), BOOL)) unless you've verified each case.
FlattenDecoder key collisions. Dot notation means a key that already contains a dot ("user.name") is indistinguishable from a nested path after decoding. If you flatten user-supplied arrays, choose a separator you can't get from the input, or validate keys first. Also, unflattening cannot reliably distinguish a list from a map on numeric keys.
JsonDecoder failure mode. Invalid JSON doesn't produce a nice typed error — check with canTransform() first, or catch around it, and decide explicitly whether a failure is a 422 or a 500. Set your JSON depth expectations; deeply nested payloads can exceed the decoder's limits.
Type narrowing is your job, not the library's. transform() returns mixed. PHPStan/Larastan will flag every call site. Either add @phpstan-assert-style wrapper methods, or funnel calls through small typed helpers (stringOf(), arrayOf(), boolOf()) in a service class. This is the single biggest ergonomic tax of adopting the package.
Bind your own contract, not theirs. Pin ^1.4 and type-hint App\Contracts\Transformer with a thin adapter delegating to the library. Minor releases here have historically added classes (1.2, 1.3 both did), and a 0-star library can rename things. The adapter costs ten lines and insulates your whole codebase.
Don't inject resolvers directly. FirstMatchResolver/RecursionResolver constructors take arrays of converters; type-hinting them in controllers spreads configuration everywhere. One singleton in the container, built from a single factory method, is the maintainable shape.
Performance: cache the decision. canTransform() on a FirstMatchResolver walks the chain and may construct/match repeatedly. In a hot loop over 10k rows, call canTransform() once outside the loop, or — better — know statically what type you have and call transform() directly. Transformers themselves are cheap, allocation-light objects, so singleton binding (not per-call new) is fine.
No Eloquent cast integration. There's no Casts class here. If you want $model->meta to come back normalized, write a CastsAttributes implementation that delegates to your transformer — that's the clean seam, and it keeps the package out of your models.
SymfonyNormalizerWrapper requires symfony/serializer. Composer won't pull it in for you. If it's not already installed, skip the wrapper entirely — you're not missing anything for a Laravel-only app.
Debugging a chain: instantiate each converter on its own in tinker and call canTransform() on representative values; log the chain order. Nine times out of ten a "wrong output" bug is a converter earlier in the list claiming the subject before the one you wanted.
Version/status caveat: 0 GitHub stars, no dependents, and a 2026-09-25 release date. That doesn't make it bad code (the test/coverage badges and per-class docs suggest otherwise), but treat it as a convenience dependency: wrap it, test your chain, and keep the escape hatch of replacing it with 50 lines of match (true) in your own Transformer service if it goes unmaintained.
How can I help you explore Laravel packages today?