popo/generator
popo/generator scaffolds Plain Old PHP Object (POPO) classes from your database tables, reducing boilerplate and helping Laravel developers build a simple, maintainable data layer.
popo/generator is a build-time tool: you describe your data structures in YAML, it emits plain PHP classes. Treat the YAML as the source of truth and the generated classes as committed artifacts — not something you edit.
composer require --dev popo/generator
It ships a CLI entry point (typically vendor/bin/popo); if you pull in the Laravel bridge you also get a service provider and an artisan command. Confirm the exact binary name and config keys in the repo README before wiring up CI — this package moves fast (recent release) and option names have shifted between versions.
First three steps:
examples/ directory in the repo first. The YAML dialect is package-specific; the schema syntax is where you will spend 90% of your learning time, not the generated PHP.php artisan vendor:publish --tag=popo, or edit config/popo.php) and set two things: where schemas live (popo/ or resources/popo/) and the output path/namespace (app/Dto, App\Dto).# popo/user.yml
App\Dto\User:
properties:
id: int
email: string
name: string?
roles:
type: array
of: string
vendor/bin/popo generate
# or
php artisan popo:generate
Then composer dump-autoload if you introduced a new namespace, and inspect the generated file. Do this on day one — the shape of the output (public properties vs. getters/setters, whether fromArray/toArray exist, how nullables are typed) determines whether this package fits your codebase.
First real use case: a third-party webhook or API response. Instead of letting $request->all() arrays flow through controllers, jobs, and services, hydrate a generated DTO at the boundary and pass the object inward. You get autocomplete, a single place to see the payload shape, and a diff in code review when the vendor changes their contract.
Hydrate at the boundary, not in the domain. The generated class is a dumb container — do the mapping in a controller, form request, or a small *Mapper class:
final class UserController
{
public function store(StoreUserRequest $request): UserResource
{
$dto = UserDto::fromArray($request->validated());
$user = $this->createUser->handle($dto);
return new UserResource($user);
}
}
Eloquent → DTO. UserDto::fromArray($user->toArray()) works for flat models but silently drops relations and mangles dates. Be explicit:
$dto = UserDto::fromArray([
'id' => $user->id,
'email' => $user->email,
'name' => $user->name,
'roles' => $user->roles->pluck('name')->all(),
]);
Nested structures and collections. For array of SomeDto, most generators hydrate only the top level. Write a tiny helper rather than hand-rolling loops everywhere:
$dto->items = array_map(
fn (array $row) => LineItemDto::fromArray($row),
$payload['items']
);
Queue payloads. Generated DTOs are not automatically serialization-safe. Either convert to an array when constructing the job (new ProcessOrder($dto->toArray())) or implement __serialize/__unserialize on the job. Prefer passing scalars into the job and rebuilding the DTO in handle() — it keeps your queue contract stable when the schema evolves.
API resources. Let the DTO feed JsonResource rather than returning it directly, so you control field visibility and naming:
return UserResource::make($dto); // toArray() inside the resource
Regeneration as a workflow, not a one-off. Add a composer script and a CI check so nobody edits generated code by hand:
"scripts": {
"popo": "popo generate",
"popo:check": "popo generate && git diff --exit-code app/Dto"
}
Running generate in CI and failing on a dirty diff catches both "schema changed but classes not regenerated" and "someone hand-edited a generated class."
Testing. Snapshot the generated files (assertFileExists + hash, or a golden-file comparison), and unit-test the mapper functions — not the generated getters. Test the round trip fromArray($x)->toArray() === $x, because that's where nullable and default-value bugs surface.
Layering. Keep DTOs out of your Eloquent models' business logic. They should be usable from a plain PHPUnit test with no Laravel bootstrapping — that's the whole point of "POPO."
int, float, bool, string?, and nested class references actually render in PHP. A ? that produces ?string versus string|null matters for static analysis and for strict_types callers.null, an uninitialized typed property, or a constructor error. This is the #1 runtime surprise when a vendor omits an optional field.FormRequest rules as the validation layer; don't try to encode constraints in the YAML.config/popo.php, php artisan config:clear — otherwise the generator writes to the old path and you'll debug a phantom PSR-4 error.composer dump-autoload and, in PhpStorm, a re-index. If you ever "can't find the class," check the namespace in the YAML against your PSR-4 root.app/Dto to phpunit.xml coverage excludes to keep metrics honest, but keep it in PHPStan/Psalm — generated code often reveals nullability mistakes in your own mappers.<<: merges help when many DTOs share a shape.spatie/laravel-data solves that with less ceremony. popo/generator wins when you want zero runtime dependency, plain objects, and a reviewable YAML contract for structures you don't own (vendor webhooks, legacy APIs, event payloads).How can I help you explore Laravel packages today?