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/cogentThe 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 callEach 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/ModelsThe OpenAPI and AsyncAPI formats require composer require --dev dedoc/scramble.
composer install
composer testMIT.