atldays / laravel-sculptor
This package provides a convenient interface for building complex database queries using Eloquent ORM in Laravel.
Requires
- php: ^8.2
- atldays/laravel-eloquent-filters: ^2.1
- atldays/laravel-eloquent-query-cache: ^1.1
- illuminate/cache: ^10.0|^11.0|^12.0|^13.0
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- spatie/laravel-package-tools: ^1.93
- webmozart/assert: ^1.11
Requires (Dev)
- laravel/pint: ^1.21
- mockery/mockery: ^1.6
- orchestra/testbench: ^8.1.1|^9.0|^10.0|11.x-dev
- phpunit/phpunit: ^9.5.25|^10.0|^11.0|^12.0
Suggests
- barryvdh/laravel-debugbar: For debugging
This package is auto-updated.
Last update: 2026-04-14 15:15:20 UTC
README
laravel-sculptor provides reusable query objects for Eloquent.
It moves complex read logic out of controllers and services into dedicated classes with a consistent API for:
- model binding
selectcontrol- eager loading
- filters
- result execution
- pagination
- result caching
- builder-level cache integration
Why
As Laravel applications grow, read queries often become hard to maintain:
- controllers start building large queries inline
- services repeat the same
with,select, and filtering logic - cache behavior becomes inconsistent
- query rules become difficult to test in isolation
Sculptor solves this by introducing query objects: focused classes that represent a single read operation.
Installation
Add the package via Composer:
composer require atldays/laravel-sculptor
Publish the package config if needed:
php artisan vendor:publish --tag=sculptor-config
Requirements
- PHP
^8.2 - Laravel
^10.0|^11.0|^12.0|^13.0
Core Classes
| Class | Filters | Cache | Requires query-cache-enabled model |
|---|---|---|---|
BaseQuery |
No | No | No |
Query |
Yes | No | No |
CachedQuery |
Yes | Result cache | No |
BuilderCachedQuery |
Yes | Builder cache | Yes |
These classes are the recommended entry points for day-to-day use. Lower-level traits are available when you need custom composition.
Quick Start
<?php namespace App\Queries\Post; use App\Models\Post; use Atldays\Sculptor\Concerns\QueryResultCollection; use Atldays\Sculptor\Query; class PublishedPostsQuery extends Query { use QueryResultCollection; protected string $model = Post::class; protected array $with = ['author']; public function __construct() { $this->limit(10); } }
Execute the query:
$posts = PublishedPostsQuery::result();
Or create the object first and customize it before execution:
$posts = PublishedPostsQuery::make() ->limit(5) ->withoutRelations() ->effect();
Query Building
Every query object starts with a model:
protected string $model = Post::class;
Sculptor supports:
- default
select(table.*) - custom
select - eager loading through
$with - overriding eager loads at runtime
- limiting result size
Example:
class PostIndexQuery extends Query { use QueryResultCollection; protected string $model = Post::class; protected array $select = [ 'posts.id', 'posts.title', 'posts.slug', 'posts.author_id', ]; protected array $with = [ 'author', 'categories', ]; public function __construct() { $this->limit(20); } }
Runtime helpers:
withSelect(string|array $columns)select()withRelations(string|array|Collection $relations)relations()withoutRelations()resetRelations()limit(int $limit)hasLimit()withoutLimit()model()newModel()
Where To Put Query Logic
The main value of Sculptor is not simple one-line queries. It is the ability to move large, reusable read logic into dedicated classes.
As a rule of thumb:
- use class properties such as
$model,$select, and$withfor static query structure - use the constructor for runtime configuration such as filters, limits, cache tags, or arguments passed into the query object
- override
query()when the query requires joins, subqueries, conditional clauses, aggregates, raw expressions, or other larger composition
For complex queries, start from the base implementation and then extend it:
<?php namespace App\Queries\Order; use App\Models\Order; use Atldays\Sculptor\Concerns\QueryResultCollection; use Atldays\Sculptor\Query; use Illuminate\Contracts\Database\Eloquent\Builder; class OrdersReportQuery extends Query { use QueryResultCollection; protected string $model = Order::class; protected array $with = ['customer']; public function __construct( protected readonly ?int $customerId = null, protected readonly bool $onlyPaid = true, ) { $this->limit(100); } public function query(): Builder { $query = parent::query() ->leftJoin('payments', 'payments.order_id', '=', 'orders.id') ->leftJoin('shipments', 'shipments.order_id', '=', 'orders.id') ->addSelect([ 'orders.id', 'orders.customer_id', 'orders.total', 'payments.status as payment_status', 'shipments.tracking_number', ]); if ($this->customerId !== null) { $query->where('orders.customer_id', $this->customerId); } if ($this->onlyPaid) { $query->where('payments.status', 'paid'); } return $query->orderByDesc('orders.created_at'); } }
This is the recommended pattern for larger query objects:
- keep reusable defaults in properties
- keep runtime input in the constructor
- keep heavy SQL composition in
query() - return the final Eloquent builder from
query()
That makes the query object easy to reuse, test, and evolve without pushing read logic back into controllers or services.
You can apply the same pattern to cached query objects:
<?php namespace App\Queries\Product; use App\Models\Product; use Atldays\Sculptor\Attributes\CacheStore; use Atldays\Sculptor\CachedQuery; use Atldays\Sculptor\Concerns\QueryResultCollection; use Illuminate\Contracts\Database\Eloquent\Builder; #[CacheStore('redis')] class TrendingProductsQuery extends CachedQuery { use QueryResultCollection; protected string $model = Product::class; protected array $with = ['brand', 'categories']; public function __construct( protected readonly string $region, protected readonly int $days = 7, ) { $this ->limit(20) ->setCacheFor(600) ->setCacheTags('products', "region:{$this->region}", "days:{$this->days}"); } public function query(): Builder { return parent::query() ->join('product_stats', 'product_stats.product_id', '=', 'products.id') ->where('product_stats.region', $this->region) ->where('product_stats.period_days', $this->days) ->where('products.is_active', true) ->orderByDesc('product_stats.score') ->addSelect([ 'products.id', 'products.name', 'products.slug', 'products.brand_id', 'product_stats.score', ]); } }
This gives you a single reusable class that owns:
- the query shape
- the runtime inputs
- the cache policy
- the cache tags used for later invalidation
Filters
Query includes filter support out of the box through atldays/laravel-eloquent-filters.
<?php namespace App\Queries\Post; use App\Models\Post; use App\QueryFilters\PublishedFilter; use Atldays\Sculptor\Concerns\QueryResultCollection; use Atldays\Sculptor\Query; class PublishedPostsQuery extends Query { use QueryResultCollection; protected string $model = Post::class; public function __construct() { $this ->addFilter(new PublishedFilter()) ->limit(10); } }
If you do not want filter integration, use BaseQuery.
Result Execution
Sculptor provides two execution helpers:
QueryResultCollectionforget()QueryResultFirstforfirst()QueryResultPaginatedforpaginate()
Collection Result
class UsersQuery extends Query { use QueryResultCollection; protected string $model = User::class; public function __construct() { $this->limit(25); } }
First Result
class FirstUserQuery extends Query { use QueryResultFirst; protected string $model = User::class; }
Execution API
::make(...$arguments)resolves the query object through the container->effect()executes the query object::result(...$arguments)instantiates and executes in one call
Query Execution Event
Every ::result() call dispatches Atldays\Sculptor\Events\ResultExecuted.
The event contains:
- the query class name
- the execution start time
- the execution finish time
- the execution duration in milliseconds through
duration()
Pagination
Sculptor treats pagination as a separate result type, just like collections and single-model results.
Use:
QueryResultPaginatedfor Laravel'spaginate()
Example:
<?php namespace App\Queries\Post; use App\Models\Post; use Atldays\Sculptor\Concerns\QueryResultPaginated; use Atldays\Sculptor\Query; class PaginatedPostsQuery extends Query { use QueryResultPaginated; protected string $model = Post::class; }
Runtime pagination helpers:
perPage(int $perPage)page(int $page)pageName(string $pageName)paginationColumns(string|array $columns)
Usage:
$posts = PaginatedPostsQuery::make() ->perPage(15) ->page(2) ->effect();
Or use the static shortcut:
$posts = PaginatedPostsQuery::paginate(perPage: 15, page: 2);
If your query object also accepts constructor arguments, pass them as additional named arguments:
$posts = ProductsByRegionQuery::paginate( perPage: 15, page: 2, region: 'eu', );
Pagination also works with CachedQuery, and the pagination state is included in the cache key so different pages are cached independently.
Use the dedicated pagination methods for page state. Constructor arguments should represent business input such as tenant, region, status, or date range, not paginator state.
Caching
Sculptor supports two cache strategies.
CachedQuery
CachedQuery caches the result of the query object through Laravel cache.
It does not require a custom Eloquent query builder or a special trait on the model.
<?php namespace App\Queries\Post; use App\Models\Post; use Atldays\Sculptor\Attributes\CacheStore; use Atldays\Sculptor\CachedQuery; use Atldays\Sculptor\Concerns\QueryResultCollection; #[CacheStore('array')] class CachedPublishedPostsQuery extends CachedQuery { use QueryResultCollection; protected string $model = Post::class; protected array $with = ['author']; public function __construct() { $this ->limit(10) ->setCacheFor(300) ->setCacheTags('posts', 'published'); } }
Use this when you want simple query-object-level caching and do not need deep builder integration.
BuilderCachedQuery
BuilderCachedQuery integrates with atldays/laravel-eloquent-query-cache.
Use this only when your model already uses the query-cache package integration and returns a cache-aware query builder.
<?php namespace App\Queries\Post; use App\Models\Post; use Atldays\Sculptor\Attributes\CacheStore; use Atldays\Sculptor\BuilderCachedQuery; use Atldays\Sculptor\Concerns\QueryResultCollection; #[CacheStore('redis')] class BuilderCachedPostsQuery extends BuilderCachedQuery { use QueryResultCollection; protected string $model = Post::class; protected array $with = ['author']; public function __construct() { $this ->limit(10) ->setCacheFor(300) ->setCacheTags('posts'); } }
If the model is not configured for the query-cache package, this mode fails intentionally.
Cache Configuration
Available cache helpers:
setCacheFor(int|DateTime $time)setCacheTags(string ...$tags)mergeCacheTags(string ...$tags)cacheTags()cacheBaseTag()cache()cacheStore()getCache()flushBaseCache()
Custom Cache Tags
Custom tags are useful when the same query class can produce multiple cached result groups.
For example, when a query depends on constructor arguments such as tenant, status, region, or another runtime scope, you can attach additional tags and later invalidate that group explicitly.
<?php namespace App\Queries\Post; use App\Models\Post; use Atldays\Sculptor\CachedQuery; use Atldays\Sculptor\Concerns\QueryResultCollection; class PaginatedPublishedPostsQuery extends CachedQuery { use QueryResultCollection; protected string $model = Post::class; public function __construct(public readonly string $region) { $this ->limit(10) ->setCacheFor(300) ->setCacheTags('posts', "region:{$this->region}"); } }
You can also add tags incrementally:
$query = PaginatedPublishedPostsQuery::make(region: 'eu') ->mergeCacheTags('tenant:acme');
Because getCache() returns the tagged Laravel cache repository for the query class, you can invalidate a custom group directly:
PaginatedPublishedPostsQuery::make(region: 'eu') ->getCache() ->flush();
The final tag set always includes the base tag for the query class, so class-level invalidation through flushBaseCache() remains available.
Cache Store Attributes
use Atldays\Sculptor\Attributes\CacheStore; #[CacheStore('redis')] class CachedUsersQuery extends CachedQuery { // ... }
Or resolve the store from configuration:
use Atldays\Sculptor\Attributes\CacheStoreFromConfig; #[CacheStoreFromConfig('services.cache.queries_store', 'redis')] class CachedUsersQuery extends CachedQuery { // ... }
Flush Query Cache
Flush the base cache for a query class:
CachedPublishedPostsQuery::flushBaseCache();
Or through Artisan:
php artisan sculptor:flush-cache "App\\Queries\\Post\\CachedPublishedPostsQuery"
Choosing the Right Class
Use BaseQuery when:
- you only want a plain query object
- you do not need filters
- you do not need cache
Use Query when:
- you want the default query object experience
- you want filter support
- you do not need cache
Use CachedQuery when:
- you want filter support
- you want cache
- you do not want to depend on a cache-aware Eloquent builder
Use BuilderCachedQuery when:
- you want filter support
- you already use
atldays/laravel-eloquent-query-cache - you want builder-level cache integration for the query and eager loaded relations
Traits
If you need custom composition, the package also exposes low-level and capability traits:
HasQueryHasQueryWithFiltersHasQueryWithCacheHasQueryWithBuilderCacheHasQueryWithFiltersAndCacheHasQueryWithFiltersAndBuilderCacheHasCacheHasResultQueryResultCollectionQueryResultFirst
For most applications, the preset classes are the best starting point.
Testing
Query objects are straightforward to test because they isolate read logic in dedicated classes.
This package is tested across:
- multiple Laravel versions
- multiple PHP versions
- plain queries
- filters
- result cache
- builder cache integration
License
MIT