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

Eloquentify Laravel Package

kobykorman/eloquentify

Eloquentify turns one custom SQL query with JSON-aggregated relations into fully hydrated nested Eloquent models, collapsing N+1 and R+1 round trips into a single query with zero configuration.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire it up.

composer require kobykorman/eloquentify
use Illuminate\Database\Eloquent\Model as BaseModel;
use KobyKorman\Eloquentify\EloquentifiesQueries;

class User extends BaseModel
{
    use EloquentifiesQueries;

    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

The trait is the only wiring. There is no config file, no service provider to publish, no schema changes — the package reads your existing relation methods by name and uses them to hydrate nested models. Adding the trait to a shared base model is usually the right move so you don't have to remember it per-model.

Where to look first. Read the README Quick Start end to end (it's short), then the package's own test suite — the tests are the authoritative spec for the hydration contract: accepted input shapes (stdClass rows vs arrays), null handling, nesting depth, and pivot behaviour. Confirm the exact static hydrator name and signature against the README before you write your first query; the trait exposes it as a static method on the model (referred to as fromQueryResult() below).

First use case — an admin index page. Any listing screen that loops over models and touches a relation inside the loop is the perfect first target. An index of 100 users, each rendering their 5 latest posts plus a comment count, is ~101 queries today. It becomes:

$rows = DB::select("
    SELECT
        users.id, users.name, users.email, users.created_at, users.updated_at,
        COALESCE((
            SELECT json_agg(p ORDER BY p.created_at DESC)
            FROM (
                SELECT posts.id, posts.user_id, posts.title, posts.created_at
                FROM posts
                WHERE posts.user_id = users.id
                ORDER BY posts.created_at DESC
                LIMIT 5
            ) p
        ), '[]'::json) AS posts
    FROM users
    WHERE users.active = true
    ORDER BY users.id
", []);

$users = User::fromQueryResult($rows);  // Collection<User>, ->posts is a real Collection<Post>

You keep $user->posts, $post->user, $user->toArray(), API Resources, and accessors. You lose the round trips.

Implementation Patterns

The contract is: one JSON column per relation method, named exactly after the method. That single rule drives everything else. Build the query so the top-level columns are the model's own attributes and every relation is a JSON-encoded array (or object, for belongsTo/hasOne).

Postgres — nested relations: nest another aggregate inside the posts subquery.

COALESCE((
    SELECT json_agg(p)
    FROM (
        SELECT posts.id, posts.user_id, posts.title,
        COALESCE((
            SELECT json_agg(c)
            FROM (
                SELECT comments.id, comments.post_id, comments.body
                FROM comments WHERE comments.post_id = posts.id
            ) c
        ), '[]'::json) AS comments
        FROM posts WHERE posts.user_id = users.id
    ) p
), '[]'::json) AS posts

Postgres — single related model: use row_to_json.

(SELECT row_to_json(o) FROM (
    SELECT organizations.id, organizations.name
    FROM organizations WHERE organizations.id = users.organization_id
) o) AS organization

MySQL 8:

COALESCE((
    SELECT JSON_ARRAYAGG(JSON_OBJECT(
        'id', posts.id, 'user_id', posts.user_id, 'title', posts.title
    ))
    FROM posts WHERE posts.user_id = users.id
), JSON_ARRAY()) AS posts

Build the SQL with the query builder, hydrate the raw result. You don't have to hand-write DB::select — compose with selectRaw/where/bindings and only hydrate at the end. Use ->getQuery()->get() (or DB::select($q->toSql(), $q->getBindings())) to get unhydrated rows, then pass those to the hydrator. Calling ->get() first hydrates into models with the JSON still sitting in a plain attribute, which defeats the purpose.

$q = User::query()
    ->select('users.*')
    ->selectRaw('COALESCE((SELECT json_agg(p) FROM (...) p), \'[]\'::json) AS posts')
    ->where('users.active', true)
    ->orderBy('users.id')
    ->forPage($page, 25);

$users = User::fromQueryResult($q->getQuery()->get());

Pagination. You're off the paginate() path. Two clean options: (a) run the aggregation query with LIMIT/OFFSET and a separate count() query, then hand both to new LengthAwarePaginator(...); or (b) keep a normal User::paginate() and only attach relations via the aggregate for the current page. Option (b) is often simpler and keeps sort/filter UI working. For large exports use keyset chunking (WHERE users.id > ? ORDER BY users.id LIMIT 5000) and run the query per chunk — one giant aggregate over a million rows will hurt.

Composition with scopes. Because you're writing raw SQL, global scopes and local scopes don't apply automatically. Either inline the predicate (AND users.deleted_at IS NULL) or reuse a scope's SQL via ->toSql() fragments. Keeping a small private method that returns the base Builder keeps the tenant/soft-delete filters in one place.

Serialization comes free. Hydrated models flow through toArray(), JsonResource, ->load() (for relations you didn't include), casts, accessors, and $appends exactly like query-builder models. This is the main reason to prefer it over raw json_agg + manual array shaping — your API layer doesn't need to know.

Put the SQL somewhere it can be tested. A dedicated query class or repository method (UserIndexQuery::sql() + UserIndexQuery::hydrate()) beats an inline heredoc in a controller. It also gives you a natural place to keep the Postgres/MySQL variants apart if you support both.

Many-to-many: include the pivot columns in the JSON and verify pivot hydration with a test before relying on it. If $user->roles->first()->pivot isn't populated as expected, set the relation manually post-hydration rather than assuming.

Gotchas and Tips

Column names are load-bearing. A JSON column named post instead of posts will not hydrate the posts relation — at best it becomes a stray attribute, at worst it throws. Same for nested keys. Rename the SQL alias, not the relation method (relations are used elsewhere in the app).

Always COALESCE the aggregate to []. json_agg over zero rows returns NULL, not an empty array. Hydrate a null and $user->posts becomes null instead of an empty Collection, and every foreach / ->count() in your views breaks. Postgres: '[]'::json. MySQL: JSON_ARRAY().

Select the primary key and the foreign keys. Hydrated models need id to be "real" models — without it $post->exists is false, is() fails, and route generation produces null. Child rows need their own id plus the parent's FK (posts.user_id) or the relation is built but nothing links it to the parent.

Include every column you'll read, including timestamps. Hydration maps columns to attributes directly; anything you forgot to SELECT is simply absent. $post->created_at on a missing attribute is null, and ->diffForHumans() on it is a silent null rather than an error — the kind of bug found in production, not in review. Add a test asserting on the shape of one hydrated child.

Soft deletes and global scopes are not applied for you. Your correlated subqueries bypass the model entirely. Every WHERE posts.deleted_at IS NULL you'd normally get for free must be written by hand — and consistently, or your index page and your detail page will disagree on counts.

Mass assignment is bypassed. Hydration sets attributes directly, so $guarded / $fillable do not protect you. If you SELECT users.* on a table with a stripe_secret column, it lands on the model and serializes into your API response. Select columns explicitly.

Type coercion is lossy at the JSON boundary. JSON has no native date type; Postgres renders timestamps as strings, MySQL may return numbers as strings. Your $casts fix most of it on the way back in, but booleans and decimals are the usual suspects — verify is_admin and balance after hydration, and don't skip cast definitions just because "the JSON already has it."

MySQL ordering in JSON_ARRAYAGG is unreliable. Postgres lets you write json_agg(p ORDER BY p.created_at DESC); MySQL's aggregate has no reliable ORDER BY hook. Order inside a derived table and don't trust the result, or sort in PHP after hydration for the small per-parent counts this pattern is best at.

Don't expect lazily-loaded relations to stay single-query. A relation you didn't aggregate will still fire a real query the moment it's touched — including inside toArray() if it's in $with. That's not a bug, but it silently recreates the N+1 you set out to kill. Audit $with/$appends on the models you hydrate.

Debug in this order: (1) dd($rows->first()) — confirm every JSON column is present, named right, and is a string not a nested object; (2) json_decode one of them to check the inner key names; (3) assert $user->relationLoaded('posts') after hydration; (4) only then reach for EXPLAIN ANALYZE, because a correlated-subquery aggregate that misses an index on the FK is genuinely slower than the N+1 it replaced. Index every join column.

Extension points: the hydrator is a trait method, so you can override it in your base model to post-process (e.g. attach pivot rows, normalize empty arrays, strip columns). Model events do fire on hydration in the usual way — if you have retrieved listeners doing heavy work, they'll now run once per nested model, which can partially undo your savings.

Vet the dependency. Small package, low star count, zero dependents. Pin the version in composer.json, read the changelog before upgrading, and keep a test that asserts a hydrated nested model round-trips (assertInstanceOf(Post::class, $user->posts->first()) plus a query-count assertion via DB::enableQueryLog()). That test is your upgrade safety net and the cheapest insurance against a hydration contract change.

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.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core