Cogent helps your Laravel app render models fast, without writing with() and withCount() everywhere.
You declare what a resource needs once — right on the resource — and Cogent loads it for every model, in one batched pass, just before it renders.
#[Includes(['author' => UserResource::class])] #[Counts('comments')] final class PostResource extends JsonResource { use PreparesResources; public function toArray(Request $request): array { return [ 'id' => $this->id, 'author' => new UserResource($this->whenLoaded('author')), 'comments_count' => $this->whenCounted('comments'), ]; } } // One query for posts, one for authors, one for comment counts — no matter how many posts. return PostResource::collection(Post::query()->paginate());
Cogent works across the whole path of a Laravel object:
| Area | What Cogent does |
|---|---|
| Eloquent / Fluent | Plug, include and count relations on collections already in memory — no extra queries. |
| Request (Input/Output) | Load only the relations a request asks for (?include=); prepare resource relations and counts before rendering; cache rendered output at the resource or controller level. |
| Event / Broadcast | Send models over WebSockets as their resources; restore queued models without re-querying; batch broadcasts and check payload size. |
composer require webong/cogent
The service provider is auto-discovered. Requires PHP 8.3+ and Laravel 12 or 13.
Cogent starts with collections you already have. Instead of letting Eloquent re-query relations, you attach, include, or count what's already loaded.
$conversations->plug('channel', $channels->get($conversation->channel_id)); // Or resolve per model: $conversations->plug('channel', fn (Conversation $c) => $channels->get($c->channel_id)); // Only attach to models that don't have it loaded yet: $conversations->plugMissing('channel', $channels);
include() walks relations that are in memory and returns every related model, deduplicated so the same row is never held twice:
$graph = $conversations->include('channel', 'participants'); // no queries // Fetch anything not loaded yet, batched by model class: $conversations->includeMissing('channel.contacts');
batchCount() writes {relation}_count on every model with one grouped query:
$conversations->batchCount('messages'); // now $conversation->messages_count is available
#[Includable] declares relations that load only when the request names them:
#[Includable(['author' => UserResource::class], 'comments')] final class PostResource extends JsonResource { use PreparesResources; }
A client can then request ?include=author,comments.post. Anything not declared is ignored, so a client can never load more than the resource allows.
Need a different query parameter than ?include=? Register it once in a service provider:
Cogent::includeParameter('with');
Cogent provides one interface for JSON:API-style query params (filter[], sort, include), OpenAPI docs, and AsyncAPI docs — all driven by resource attribute declarations. You don't use spatie/laravel-query-builder directly; Cogent wraps it.
Declare what your resource accepts right on the resource class:
use Webong\Cogent\Attributes\Filterable; use Webong\Cogent\Attributes\Includable; use Webong\Cogent\Attributes\Sortable; use Webong\Cogent\Concerns\PreparesResources; #[Filterable(['title' => 'exact', 'body' => 'partial'])] #[Sortable(['title' => 'asc', 'created_at' => 'desc'])] #[Includable(['author' => UserResource::class], 'comments')] final class PostResource extends JsonResource { use PreparesResources; }
Then query through Cogent::query() instead of spatie's QueryBuilder::for():
use Webong\Cogent\Cogent; // GET /posts?filter[title]=Hello&sort=-created_at&include=author $posts = Cogent::query(Post::query(), PostResource::class)->get(); // Or paginated: $posts = Cogent::query(Post::query(), PostResource::class)->paginate(15); // Or cursor-paginated: $posts = Cogent::query(Post::query(), PostResource::class)->cursorPaginate(15); // Access the underlying spatie QueryBuilder for advanced customization: $spatie = Cogent::query(Post::query(), PostResource::class)->spatie();
Cogent::query() reads #[Filterable], #[Sortable], and #[Includable] from the resource class and applies them automatically through spatie. When the resource resolves, PreparesResources loads anything still missing and applies #[Counts] — spatie eager-loads the includes, Cogent fills in the gaps.
Attribute options:
| Attribute | What it declares | Query param |
|---|---|---|
#[Filterable(['title' => 'exact'])] |
Filterable fields and types | filter[title] |
#[Sortable(['title' => 'asc'])] |
Sortable fields and directions | sort |
#[Includable(['author'], 'comments')] |
Includable relations | ?include= |
Filter types: exact, partial, beginsWith, endsWith, belongsTo, scope. Default: exact.
Sort directions: asc, desc. Default: asc.
use Webong\Cogent\QueryBuilder\CogentQueryBuilder; $metadata = CogentQueryBuilder::metadataFor(PostResource::class); // ['author' => null, 'comments' => null] $metadata['includes']; // ['title' => 'exact', 'body' => 'partial'] $metadata['filters']; // ['title' => 'asc', 'created_at' => 'desc'] $metadata['sorts'];
Pass it to PlanExporter::openApi() so OpenAPI output includes filter[] and sort parameters alongside ?include=:
use Webong\Cogent\Resources\PlanExporter; $exporter = new PlanExporter(); $document = $exporter->openApi( [PostResource::class], 'include', [PostResource::class => CogentQueryBuilder::metadataFor(PostResource::class)], );
The resulting OpenAPI document gets filter[title], filter[body], and sort query parameters in components.parameters, plus an x-cogent extension carrying the filter type and sort direction for each.
Combined with #[Includable] on your resources, this gives you full JSON:API-style query support: filtering, sorting, and includes — all declared, all documented, all validated.
Add PreparesResources to a resource and declare what it renders:
#[Includes(['author' => UserResource::class, 'channel'])] #[Counts('comments')] final class PostResource extends JsonResource { use PreparesResources; public function toArray(Request $request): array { return [ 'id' => $this->id, 'author' => new UserResource($this->whenLoaded('author')), 'channel' => $this->whenLoaded('channel'), 'comments_count' => $this->whenCounted('comments'), ]; } }
#[Includes]loads the listed relations for every model, once, sharing duplicate instances.#[Counts]writes{relation}_counton every model with one grouped query.- Relations already loaded are left alone.
Use it anywhere:
return PostResource::collection(Post::query()->paginate()); return Post::query()->get()->toResourceCollection(PostResource::class); return new PostResource($post);
A single resource prepares its one model. Collections and paginators prepare every model in one batched pass.
For anything the attributes can't express, add a static prepare() hook. It runs once per resolve with every model:
public static function prepare(CogentCollection $posts, Request $request): void { $posts->plugMissing('channel', $request->user()->channels); }
Cache what a resource renders so a cache hit skips preparing and toArray() entirely:
#[Remember(300)] #[Includes(['author' => UserResource::class])] #[Counts('comments')] final class PostResource extends JsonResource { use PreparesResources; }
return PostResource::collection($posts); // cached for 300 seconds return PostResource::collection($posts)->remember(60); // or per call
Each authenticated user gets their own entry unless you mark it shared:
#[Remember(300, shared: true)]
#[Remember] lives on the resource. If you want to cache an entire controller response — including anything the controller adds beyond the resource — use the remember() macro on the request or response, or inject OutputCache directly. This is an Input/Output cache: the input is the models plus anything the output varies on, and the output is the rendered array. It auto-forgets when any of those models (or rows reached through their loaded relations) is saved or deleted.
Cogent ships a config file with named cache surfaces, each with its own enabled flag and ttl:
return [ 'domains' => ['posts', 'users', 'comments', 'channels', 'conversations'], 'registry' => [ 'posts' => ['list', 'counts', 'detail', 'scope'], 'users' => ['list', 'detail', 'scope'], 'comments' => ['list', 'counts'], 'channels' => ['scope'], 'conversations' => ['list', 'counts', 'context', 'scope', 'detail'], ], 'surfaces' => [ 'list' => ['enabled' => false, 'ttl' => 30], 'counts' => ['enabled' => false, 'ttl' => 20], 'context' => ['enabled' => false, 'ttl' => 60], 'scope' => ['enabled' => false, 'ttl' => 120], 'detail' => ['enabled' => false, 'ttl' => 30], ], ];
Enable surfaces via environment variables:
COGENT_LIST_CACHE_ENABLED=true
COGENT_LIST_CACHE_TTL=30
public function index(Request $request) { $posts = Post::query()->orderBy('created_at', 'desc')->get(); return $request->remember( resource: PostResource::class, models: $posts->all(), vary: ['locale' => app()->getLocale(), 'page' => $request->input('page')], ttl: 300, build: fn () => PostResource::collection($posts)->resolve($request), ); }
public function show(Post $post) { return response()->json(new PostResource($post))->remember( resource: PostResource::class, models: [$post], ttl: 300, ); }
use Webong\Cogent\Resources\OutputCache; public function index(OutputCache $cache, Request $request) { $posts = Post::query()->orderBy('created_at', 'desc')->get(); return $cache->remember( resource: PostResource::class, models: $posts->all(), vary: ['locale' => app()->getLocale(), 'page' => $request->input('page')], ttl: 300, build: fn () => PostResource::collection($posts)->resolve($request), ); }
Configure caching declaratively with #[RememberResponse] on a controller method. Register the remember.response middleware on the route:
use Webong\Cogent\Attributes\RememberResponse; // In routes/web.php or routes/api.php: Route::get('/posts', [PostController::class, 'index']) ->middleware('remember.response');
Then on the controller method:
use Webong\Cogent\Attributes\RememberResponse; #[RememberResponse(surface: 'list', domain: 'posts', vary: ['locale', 'page'])] public function index(Request $request) { $posts = Post::query()->orderBy('created_at', 'desc')->get(); return PostResource::collection($posts)->resolve($request); }
Options:
| Option | Description |
|---|---|
surface |
Named cache surface (list, counts, detail, scope, context). Must be enabled in config/cogent.php. |
domain |
Cache domain grouping related resources (posts, users, comments, etc.). |
vary |
Request input keys or closures that differentiate the cache entry. |
shared |
One entry for everyone instead of per authenticated user. |
when |
Closure receiving the request; only cache when it returns true. |
vary accepts request input keys (strings) or closures that receive the request and return a value:
#[RememberResponse(
surface: 'list',
domain: 'posts',
vary: [
'locale',
'page',
fn (Request $request) => $request->header('x-app-version'),
],
)]Only cache when a condition is met:
#[RememberResponse(
surface: 'detail',
domain: 'posts',
when: fn (Request $request) => $request->query('cache') === '1',
)]Domains and surfaces used in #[RememberResponse] attributes are automatically registered into config/cogent.php at runtime.
Turn on the dev guard so any query that sneaks in while a resource renders throws instead of silently becoming an N+1:
// In a service provider's boot(): Cogent::guardResources(! app()->isProduction()); Cogent::handleResourceQueriesUsing(function (string $resource, \Illuminate\Database\Events\QueryExecuted $query): void { logger()->warning("{$resource} queried while rendering", ['sql' => $query->sql]); });
Send models over WebSockets as their resources, so clients get the same shape over a socket as over HTTP:
use Webong\Cogent\Attributes\WithResource; use Webong\Cogent\Concerns\BroadcastsResources; final class PostUpdated implements ShouldBroadcast { use BroadcastsResources; public function __construct( public Post $post, #[WithResource(EditorResource::class)] public User $editor, ) {} }
Models without a resource fall back to toArray().
For models that broadcast their own events, use BroadcastsAsResource instead of Laravel's BroadcastsEvents — every model event is sent as the model's resource:
#[UseResource(PostResource::class)] class Post extends Model { use BroadcastsAsResource; }
Batch many model broadcasts so every model is prepared in one pass and nothing is sent if the callback throws:
Cogent::batchBroadcasts(function () use ($posts): void { $posts->each->update(['published' => true]); });
Check payloads against the driver limit (Pusher 10KB, Reverb ~10,000 bytes):
Cogent::limitBroadcasts(10_000); Cogent::handleOversizedBroadcastsUsing(function (object $event, int $bytes, int $limit): void { logger()->warning($event::class." broadcast is {$bytes} bytes, over {$limit}"); });
Laravel's SerializesModels re-queries every restored model, losing counts and cached relations. Use SerializesPreparedModels instead — restored models load what their resource renders, and cacheable relations come from the cache:
use Webong\Cogent\Concerns\SerializesPreparedModels; final class PostUpdated implements ShouldBroadcast { use BroadcastsResources; use SerializesPreparedModels; public function __construct( public Post $post, #[WithResource(EditorResource::class)] public User $editor, ) {} }
AsyncAPI is documentation only — it describes what your broadcast events look like; it doesn't handle WebSocket connections at runtime (that's Reverb/Pusher's job).
Generate an AsyncAPI 3.0 document so frontend teams know what channels to subscribe to and what payload shapes to expect:
php artisan cogent:plans --format=asyncapi \
--events-path=app/Events \
--models-path=app/Models \
--title="My Application WebSockets"It scans events using BroadcastsResources/SnapshotsBroadcasts and models using BroadcastsAsResource, producing:
- Channels — where events broadcast (public, private, presence), with dynamic addresses marked
- Operations — send actions for each event
- Messages — event names and payload schemas
- Schemas — the resource shapes broadcast over the socket
The OpenAPI and AsyncAPI formats require composer require --dev dedoc/scramble.
# Human-readable table of what every preparing resource loads, counts and caches php artisan cogent:plans # Same as JSON, for tooling php artisan cogent:plans --format=json # OpenAPI 3.1 components: include parameters, plans and Scramble-inferred response schemas php artisan cogent:plans --format=openapi # AsyncAPI 3.0 document: broadcast channels, event names and resource payloads php artisan cogent:plans --format=asyncapi \ --events-path=app/Events --models-path=app/Models --title="My broadcasts"
Scan custom directories:
php artisan cogent:plans --path=app/Http/Resources/Api --format=json php artisan cogent:plans --format=asyncapi --events-path=app/Events --models-path=app/Models
The OpenAPI and AsyncAPI formats require composer require --dev dedoc/scramble.
composer install
composer testMIT.