Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Util Transformer Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. docs/converters/BasicType.md — the catch‑all converter; read this to understand the canTransform() contract before anything else.
  2. docs/resolvers/FirstMatch.md and docs/resolvers/Recursion.md — these two are what you'll actually use 90% of the time.
  3. The rest of 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 ships TransformerInterface, but if a minor version renames it, bind against your own App\Contracts\Transformer adapter 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

Implementation Patterns

1. Treat FirstMatchResolver as your "sane default" serializer

Ordering 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.

2. Decode → transform pipelines with ChainedResolver

ChainedResolver 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);
}

3. Use it in API Resources instead of hand-rolled 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.

4. Normalize request input before validation

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.

5. Structured exception context in the exception handler

// 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).

6. Middleware for boundary normalization

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.

7. Reuse existing Symfony normalizers

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.

8. Testing helper

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.

9. Custom transformers are the intended extension point

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.

10. Cache-key and DB-column flattening

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)));

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc