ncphillips/laravel-doctrine-query

Ergonomic querying traits for Doctrine Entities in Laravel

Maintainers

Package info

github.com/ncphillips/laravel-doctrine-query

pkg:composer/ncphillips/laravel-doctrine-query

Transparency log

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.4.0 2026-07-25 20:01 UTC

This package is auto-updated.

Last update: 2026-07-27 14:09:21 UTC


README

Ergonomic querying traits for Doctrine entities in Laravel.

This package gives Doctrine entities an Eloquent-flavoured static query API so you can write User::find(1) instead of reaching for the EntityManager and a repository at every call site.

It also provides a MagicQueryBuilder that exposes shorthand where*, andWhere*, orWhere*, and is* methods for each mapped field, reducing DQL boilerplate.

Installation

composer require ncphillips/laravel-doctrine-query

The service provider is registered automatically via Laravel package discovery.

Usage

Add the Queryable trait to a Doctrine entity:

use Doctrine\ORM\Mapping as ORM;
use Ncphillips\LaravelDoctrineQuery\Queryable;

#[ORM\Entity]
class User
{
    use Queryable;

    // ...
}

You can then query it statically:

User::all();                              // array<User>
User::find($id);                          // ?User
User::findBy(['admin' => true]);          // array<User>
User::findOneBy(['name' => 'Ada']);       // ?User
User::count(['admin' => true]);           // int

User::query()                             // MagicQueryBuilder, aliased "user"
    ->isAdmin()                           // shorthand for ->andWhere('user.admin = :admin') with true
    ->getQuery()
    ->getResult();

The EntityManager is resolved from the Laravel container on demand, so entities stay free of any constructor wiring.

Magic Query Builder

query() returns a MagicQueryBuilder that converts property-based method calls into DQL.

For every Doctrine mapped field and to-one association, the builder generates:

  • where{Property}($value)WHERE entity.property = :property
  • andWhere{Property}($value) — chains with AND
  • orWhere{Property}($value) — chains with OR
  • is{Property}($value) — boolean shorthand, defaults to true. Intended for boolean fields; the runtime does not enforce that, but the ide-helper only generates is* stubs for them. Both naming styles resolve to the same method: isAdmin() matches a property named isAdmin if one exists, otherwise a property named admin — so isIsAdmin() is never a thing.

Passing an array to any where* method produces a WHERE ... IN clause instead:

User::query()->whereName(['Bob', 'Doug']);   // WHERE user.name IN (:name)
Post::query()
    ->whereTitle('Hello')
    ->andWherePublished(true)
    ->isPublished()          // same as isPublished(true)
    ->isPublished(false)     // negate
    ->orderBy('post.createdAt', 'DESC')
    ->getQuery()
    ->getResult();

User::query()
    ->whereName('Bob')
    ->orWhereName('Doug')    // WHERE user.name = :name OR user.name = :name_2
    ->getQuery()
    ->getResult();

Repeated clauses on the same property bind distinct parameters (:name, :name_2, ...), so each keeps its own value.

Operator suffixes

Appending an operator suffix to the property name produces the matching comparison instead of equality. Every suffix works with where, andWhere, and orWhere:

Method DQL
whereTitleNot($value) post.title != :title
whereIdGreaterThan($value) post.id > :id
whereIdGreaterThanOrEqual($value) post.id >= :id
whereIdLessThan($value) post.id < :id
whereIdLessThanOrEqual($value) post.id <= :id
whereTitleLike($pattern) post.title LIKE :title
whereTitleNotLike($pattern) post.title NOT LIKE :title
whereUserIsNull() post.user IS NULL
whereUserIsNotNull() post.user IS NOT NULL
whereIdBetween($min, $max) post.id BETWEEN :id AND :id_2
whereTitleNotIn($values) post.title NOT IN (:title)

Passing an array to a where*Not method produces NOT IN, mirroring how a plain where* treats arrays as IN.

If a property's own name happens to end in an operator word, the exact property match wins and the method stays an equality clause.

Unknown methods and non-mapped property names throw a BadMethodCallException at runtime.

IDE Autocomplete

The magic builder methods work at runtime without any setup, but your IDE won't know about them automatically. For autocomplete, run the generator:

php artisan doctrine:query:ide-helper

This produces _ide_helper_doctrine.php with:

  1. Builder subclasses (PostMagicQueryBuilder, UserMagicQueryBuilder, etc.) — phantom classes extending MagicQueryBuilder with @method annotations for every mapped field and to-one association on each entity.
  2. Entity stubs — phantom classes extending each real entity with a @method static annotation on query() pointing to the correct builder, so the IDE knows what Post::query() returns.

The file uses if (false) blocks, so it has no runtime impact — your IDE parses the annotations for autocomplete without PHP ever loading them.

No changes to your entities are needed — just run the command and let the IDE re-index.

Custom Query Builders

Define a custom builder by extending MagicQueryBuilder:

use Ncphillips\LaravelDoctrineQuery\MagicQueryBuilder;

class PostQueryBuilder extends MagicQueryBuilder
{
    public function recent(): static
    {
        return $this->andWhere('post.createdAt >= :recent')
            ->setParameter('recent', new \DateTimeImmutable('-7 days'));
    }
}

Then set $queryBuilder on the entity:

use Ncphillips\LaravelDoctrineQuery\Queryable;

#[ORM\Entity]
class Post
{
    use Queryable;

    protected static ?string $queryBuilder = PostQueryBuilder::class;
}

Now Post::query()->recent() works at runtime. For IDE autocomplete:

  • Custom methods like recent() are real methods on a real class, so your IDE picks them up automatically.
  • Entity field methods (whereTitle, andWherePublished, isPublished, etc.) still come from the ide-helper. Run php artisan doctrine:query:ide-helper and it will annotate PostQueryBuilder with @method stubs for every mapped field instead of generating a phantom subclass. The entity stub's query() return type will point at PostQueryBuilder, so your IDE sees both the custom methods and the entity field methods on the same builder type.

Laravel Boost

If the host application uses Laravel Boost, this package ships its own AI guidelines and a laravel-doctrine-query skill. Boost discovers them through package auto-discovery — no configuration needed. Re-run php artisan boost:install (or your agent's guideline refresh) after installing the package to pull them in.

The resources live in resources/boost/:

  • guidelines/core.blade.php — short always-on summary injected into the host project's agent guidelines.
  • skills/laravel-doctrine-query/SKILL.md — detailed usage guidance loaded on demand.

Guiding Principles

Design philosophy and decision-making framework in GUIDING_PRINCIPLES.md.

Roadmap

These are open areas the project may grow into. Nothing here is committed — discussion is welcome via issues and pull requests.

See ROADMAP.md.

Development

composer install
composer test     # Pest + Orchestra Testbench, integration tests against SQLite
composer lint     # Laravel Pint

Release

This project uses Conventional Commits and git-cliff to manage releases.

scripts/release.sh

The script:

  1. Checks for a clean working copy.
  2. Determines the next version from commit history via git cliff --bumped-version.
  3. Prepends the new changelog entries to CHANGELOG.md.
  4. Prompts you to review, then creates a jj tag.

License

MIT