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

Popo Laravel Package

scrumble-nl/popo

Popo is a Laravel package for generating plain old PHP objects (POPOs) from Eloquent models, giving you lightweight, framework-independent data containers that keep application layers clean and decoupled, with simple Artisan commands and an expressive API.

View on GitHub
Deep Wiki
Context7

Getting Started

scrumble-nl/popo is a small Laravel package for working with POPOs (Plain Old PHP Objects) — typed, framework-light classes that mirror your database tables/models so data can move between layers (HTTP → action → repository → queue) without dragging Eloquent along. With 5 stars and zero dependents, treat it as an internal-style utility: read the source before you build on it.

Minimal setup:

composer require scrumble-nl/popo
php artisan vendor:publish --provider="Scrumble\Popo\PopoServiceProvider"

As of 2.0.2, the package no longer pulls in laravel/helpers. If your app used global helpers (snake_case(), str_*(), array_get()) that only existed because popo dragged them in, add them back explicitly before upgrading:

composer require laravel/helpers

Where to look first, in this order:

  1. vendor/scrumble-nl/popo/README.md — command signatures and the base class name.
  2. config/popo.php — output namespace/path, stub selection, table→class naming rules, excluded tables.
  3. src/ — the generator command and the base POPO class. This is ~an afternoon of reading and it will answer every question below more reliably than the docs.
  4. php artisan list | grep -i popo — confirm the actual command name and options before scripting it.

First use case: pick one read-heavy endpoint and generate a POPO for its primary table, then replace the raw Model::find() return with a POPO built from the model. That single change tells you whether the package's mapping conventions fit your codebase — and it's reversible in ten minutes if they don't.

Typical generated shape (verify against src/):

$user = UserPopo::fromModel(User::find($id));

$user->name;          // typed property, no magic __get
$user->toArray();     // array for the API layer
$user->toModel();     // back to Eloquent for persistence

Implementation Patterns

Generate, then hand-edit — don't regenerate blindly. Treat the generator as a scaffolder for the first run only. Once a POPO has business meaning (custom casts, computed accessors, validation), it belongs to your app, not to the package. If you must re-run generation after a schema change, diff the output and merge.

Repository boundary pattern. The highest-value use is making POPOs the return type of your data layer so Eloquent never leaks upward:

final class UserRepository
{
    public function find(int $id): ?UserPopo
    {
        $model = User::with('roles')->find($id);

        return $model ? UserPopo::fromModel($model) : null;
    }

    public function save(UserPopo $popo): UserPopo
    {
        $model = User::updateOrCreate(['id' => $popo->id], $popo->toArray());

        return UserPopo::fromModel($model->refresh());
    }
}

Actions/services take POPOs, not models. This is where the package earns its place: your domain methods become testable without a database, and you can construct a POPO by hand in a unit test.

final class RegisterUser
{
    public function __construct(private UserRepository $users) {}

    public function handle(CreateUserPopo $input): UserPopo
    {
        return $this->users->save(
            UserPopo::fromArray([...$input->toArray(), 'registered_at' => now()])
        );
    }
}

Collections. Map over query results rather than hydrating POPOs one at a time in a loop across the app:

$popos = User::where('active', true)->get()
    ->map(fn ($m) => UserPopo::fromModel($m));

If the package ships a collection wrapper, prefer it — but a plain Collection of POPOs is usually enough and keeps you independent of the package's internals.

Queue payloads. Serialize POPOs into job constructors instead of models. You avoid stale-model re-fetching, avoid SerializesModels surprises, and the job contract becomes explicit. Keep the POPO readonly/immutable where you can so a job can't mutate its payload mid-flight.

API resources. toArray() on the POPO plus a JsonResource wrapper gives you one place to hide columns (password, remember_token) regardless of which endpoint returns it — better than scattering $hidden across models.

Form Request → POPO. Convert validated input into an input POPO inside passedValidation() or the controller, so the action receives a typed object rather than a array $validated.

Gotchas and Tips

No global helpers any more (2.0.2 breaking edge). The package dropped laravel/helpers and swapped its own snake_case() call for Str::snake(). There is no API change, but the dependency removal is a transitive break for apps that silently relied on those globals. Before upgrading, grep for array_get(, array_first(, array_last(, str_, snake_case(, camel_case(, studly_case(; if any hits are not covered by your own composer require laravel/helpers, add it. This is a composer update that can fail at runtime, not at install time.

PHP 8.5 helper collision. The reason for the removal: laravel/helpers defines array_first()/array_last() with Laravel's two-argument signature, which conflicts with PHP 8.5's native one-argument versions. If you're targeting 8.5, do not blanket-add laravel/helpers back — replace any array_first($arr) usage with Arr::first($arr) (or $arr[array_key_first($arr)]) instead, and only keep the helpers package if you genuinely need the string helpers.

Generated code and schema drift. The biggest day-to-day risk with a low-traffic package like this: regeneration is manual, so POPOs silently go stale after a migration. Add a CI check that diffs a fresh generation against what's committed, or at minimum a make popo target in your team docs that everyone runs after php artisan migrate.

Type mapping is lossy. Database types don't map cleanly to PHP: tinyint(1) vs bool, decimal vs float vs string, json vs array, nullable timestamps. Inspect the generated properties against your actual schema and fix decimal to string (money) and json to array by hand. Do this once per table and it never bites you again.

Dates and timezones. If the package casts to Carbon, check whether it respects config('app.timezone') and whether toModel() serializes back in the DB's expected format. A round-trip test (model → popo → model) is the cheapest way to catch an off-by-one-hour bug before production.

Relations are usually not included. Most POPO generators only cover a single table's columns. Anything joined or eager-loaded must be added manually as an extra property with an explicit type — and if you store a related POPO, be deliberate about whether toModel() should touch it (it probably shouldn't).

Snake_case ↔ camelCase. Decide once, globally, whether POPO properties mirror column names or are camelCased. Mixing conventions between generated and hand-written POPOs produces silent nulls when fromArray() looks for a key that isn't there. Grep for fromArray call sites after any renaming.

toArray() leaking sensitive columns. Generated POPOs include every column, including password, tokens, and internal flags. Never return a POPO's toArray() directly from a controller without an allow-list.

Fillable/guarded asymmetry. If toModel() uses fill(), a column missing from $fillable is silently dropped — the save succeeds and nothing updates. When a save "does nothing," dump $popo->toArray() and compare against $model->getFillable() first.

Config and stub caching. If you publish stubs or config, remember php artisan config:clear after edits, and note that config:cache in production will freeze your output paths — deploy pipelines that generate POPOs at build time must run generation after config is cached or against an explicit path.

Extension points worth knowing: custom stubs in config/popo.php (change the base class, add declare(strict_types=1), add a readonly modifier), a project-level base class that your generated POPOs extend (put shared behaviour like toJson() there rather than editing every file), and wrapping fromModel() in a trait if you need per-model custom mapping without touching generated code.

Maintenance risk — improved but still pin it. The 2.0.2 release is a good sign: dead packages don't remove dependencies or widen CI, and the matrix now covers Laravel 10–13 on PHP 8.1–8.5. That said, zero dependents and a very small surface still mean you should pin the version in composer.json, keep the package behind your own repository/action interfaces, and budget an hour to vendor the generator into your app if it goes quiet. Because the output is plain PHP, an exit strategy is cheap: you keep the POPOs and drop the dependency.

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.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast