The jsonapi-symfony library automatically generates standard CRUD routes for your JSON:API resources. However, sometimes you need custom endpoints that don't fit the standard resource operations. The #[JsonApiCustomRoute] attribute allows you to define these custom routes directly on your entity classes or controller classes.
Custom routes are useful for:
- Resource actions: Publishing articles, archiving posts, activating users
- Search endpoints: Full-text search, filtered queries
- Bulk operations: Batch updates, mass deletions
- Aggregation endpoints: Statistics, reports, summaries
- Workflow actions: Approval processes, state transitions
- Custom business logic: Any operation that doesn't fit standard CRUD
Define custom routes directly on your entity classes using the #[JsonApiCustomRoute] attribute:
<?php
use AlexFigures\Symfony\Resource\Attribute\JsonApiResource;
use AlexFigures\Symfony\Resource\Attribute\JsonApiCustomRoute;
#[JsonApiResource(type: 'articles')]
#[JsonApiCustomRoute(
name: 'articles.publish',
path: '/articles/{id}/publish',
methods: ['POST'],
controller: 'App\Controller\PublishArticleController'
)]
#[JsonApiCustomRoute(
name: 'articles.archive',
path: '/articles/{id}/archive',
methods: ['POST'],
controller: 'App\Controller\ArchiveArticleController',
requirements: ['id' => '\d+']
)]
class Article
{
// ... entity properties and methods
}You can also define custom routes on dedicated controller classes:
<?php
use AlexFigures\Symfony\Resource\Attribute\JsonApiCustomRoute;
#[JsonApiCustomRoute(
name: 'articles.search',
path: '/articles/search',
methods: ['GET'],
resourceType: 'articles',
controller: 'App\Controller\SearchController::search',
priority: 10 // High priority to ensure it matches before /articles/{id}
)]
#[JsonApiCustomRoute(
name: 'articles.trending',
path: '/articles/trending',
methods: ['GET'],
resourceType: 'articles',
defaults: ['_format' => 'json']
)]
class SearchController
{
public function search(): Response
{
// Search implementation
}
public function trending(): Response
{
// Trending implementation
}
}The #[JsonApiCustomRoute] attribute supports all standard Symfony route options:
name: Unique route name (string)path: URL path pattern (string)
methods: HTTP methods (array, default:['GET'])controller: Controller class or method (string)resourceType: Associated resource type (string)defaults: Default route parameters (array, default:[])requirements: Route parameter requirements (array, default:[])description: Human-readable description (string, default:null)priority: Route priority for ordering (int, default:0)
You must specify either controller or resourceType (or both):
controller: Specifies the exact controller to handle the routeresourceType: When used on a controller class, indicates which resource type this route belongs to
#[JsonApiResource(type: 'articles')]
#[JsonApiCustomRoute(
name: 'articles.publish',
path: '/articles/{id}/publish',
methods: ['POST'],
controller: 'App\Controller\Article\PublishController',
requirements: ['id' => '\d+'],
description: 'Publish an article'
)]
#[JsonApiCustomRoute(
name: 'articles.unpublish',
path: '/articles/{id}/unpublish',
methods: ['POST'],
controller: 'App\Controller\Article\UnpublishController',
requirements: ['id' => '\d+']
)]
class Article
{
// ...
}#[JsonApiCustomRoute(
name: 'articles.search',
path: '/articles/search',
methods: ['GET', 'POST'],
resourceType: 'articles',
description: 'Full-text search for articles',
priority: 10 // High priority to avoid conflict with /articles/{id}
)]
#[JsonApiCustomRoute(
name: 'articles.filter',
path: '/articles/filter/{category}',
methods: ['GET'],
resourceType: 'articles',
requirements: ['category' => '[a-z-]+'],
priority: 5 // High priority for static path segment
)]
class ArticleSearchController
{
// ...
}#[JsonApiCustomRoute(
name: 'articles.bulk.publish',
path: '/articles/bulk/publish',
methods: ['POST'],
resourceType: 'articles',
description: 'Publish multiple articles at once'
)]
#[JsonApiCustomRoute(
name: 'articles.bulk.delete',
path: '/articles/bulk/delete',
methods: ['DELETE'],
resourceType: 'articles'
)]
class ArticleBulkController
{
// ...
}#[JsonApiCustomRoute(
name: 'articles.stats',
path: '/articles/statistics',
methods: ['GET'],
resourceType: 'articles',
defaults: ['_format' => 'json']
)]
#[JsonApiCustomRoute(
name: 'articles.report',
path: '/articles/report/{period}',
methods: ['GET'],
resourceType: 'articles',
requirements: ['period' => 'daily|weekly|monthly'],
defaults: ['period' => 'daily']
)]
class ArticleStatsController
{
// ...
}The controller parameter requirements depend on where you place the attribute:
The controller parameter is required when placing the attribute on entity classes:
#[JsonApiResource(type: 'articles')]
#[JsonApiCustomRoute(
name: 'articles.publish',
path: '/articles/{id}/publish',
methods: ['POST'],
controller: 'App\Controller\PublishController::publish' // Required!
)]
class Article
{
// ...
}When placing the attribute on controller classes, you have two options:
Option 1: Explicit Controller (Recommended)
#[JsonApiCustomRoute(
name: 'articles.search',
path: '/articles/search',
methods: ['GET'],
controller: 'App\Controller\SearchController::search', // Explicit method
resourceType: 'articles'
)]
class SearchController
{
public function search(): Response { /* ... */ }
}Option 2: Invokable Controller
#[JsonApiCustomRoute(
name: 'articles.search',
path: '/articles/search',
methods: ['GET'],
resourceType: 'articles'
// No controller parameter needed for invokable controllers
)]
class SearchController
{
public function __invoke(): Response { /* ... */ }
}controller parameter, you'll get a clear error message explaining what to do.
Route priority is crucial for ensuring your custom routes are matched correctly. The priority system works as follows:
- Priority > 0: Routes are added BEFORE auto-generated routes (high priority)
- Priority ≤ 0: Routes are added AFTER auto-generated routes (low priority)
- Default priority: 0 (low priority)
Consider this common scenario:
#[JsonApiResource(type: 'articles')]
#[JsonApiCustomRoute(
name: 'articles.search',
path: '/articles/search',
methods: ['GET'],
controller: 'App\Controller\SearchController::search',
priority: 10 // HIGH PRIORITY - Essential!
)]
class Article
{
// ...
}Without high priority (priority ≤ 0):
- Auto-generated route:
GET /articles/{id}(matches first) - Custom route:
GET /articles/search(never reached!) - Request to
/articles/search→ matches/articles/{id}withid="search"
With high priority (priority > 0):
- Custom route:
GET /articles/search(matches first) ✅ - Auto-generated route:
GET /articles/{id}(matches other requests) - Request to
/articles/search→ correctly matches custom route
// High priority - added before auto-generated routes
#[JsonApiCustomRoute(
name: 'articles.search',
path: '/articles/search',
methods: ['GET'],
resourceType: 'articles',
priority: 10 // Must be > 0 to work correctly
)]
// Low priority - added after auto-generated routes
#[JsonApiCustomRoute(
name: 'articles.archive',
path: '/articles/archive',
methods: ['POST'],
resourceType: 'articles',
priority: 0 // Default - safe for non-conflicting paths
)]
// Very high priority - for critical routes
#[JsonApiCustomRoute(
name: 'articles.trending',
path: '/articles/trending',
methods: ['GET'],
resourceType: 'articles',
priority: 100 // Highest priority
)]- Use high priority (> 0) for static paths that might conflict with
/{id}patterns - Use default priority (0) for unique paths that won't conflict
- Higher numbers = higher priority within the same priority group
- Common priority values:
100: Critical system routes10: Standard custom endpoints (search, trending, etc.)1: Minor custom endpoints0: Default (non-conflicting routes)-1: Fallback routes
Custom route names that follow the exact pattern jsonapi.{type}.{action} will be automatically transformed according to your configured naming convention:
// With kebab-case naming convention:
#[JsonApiCustomRoute(
name: 'jsonapi.blog_posts.publish', // Will become 'jsonapi.blog-posts.publish'
// ...
)]Complex route names are preserved exactly as specified:
#[JsonApiCustomRoute(
name: 'jsonapi.articles.actions.publish', // Preserved exactly
// ...
)]
#[JsonApiCustomRoute(
name: 'custom.articles.special', // Preserved exactly
// ...
)]- Use descriptive names: Make route names self-documenting
- Group related routes: Use consistent naming patterns for related operations
- Specify requirements: Add parameter validation where appropriate
- Document your routes: Use the
descriptionparameter for complex operations - Consider priority: Use priority when route order matters
- Follow REST principles: Even custom routes should follow RESTful conventions when possible
Custom routes work alongside the automatically generated JSON:API routes. The library will generate both:
- Standard CRUD routes:
GET /articles,POST /articles,GET /articles/{id}, etc. - Your custom routes:
POST /articles/{id}/publish,GET /articles/search, etc.
All routes respect your configured route prefix and naming convention settings.
New in 0.3.0: Custom route handlers can leverage the full power of JSON:API query parameters (filtering, sorting, pagination) using the CriteriaBuilder API.
When implementing custom routes that return collections, you often need to:
- Apply custom business logic (e.g., filter by a path parameter like
categoryId) - Support standard JSON:API query parameters (
filter,sort,page) - Avoid duplicating the filtering/sorting/pagination logic
The CriteriaBuilder provides a fluent API for adding custom filters and conditions to the already-parsed JSON:API query parameters.
<?php
namespace App\CustomRoute;
use AlexFigures\Symfony\CustomRoute\Attribute\NoTransaction;
use AlexFigures\Symfony\CustomRoute\Context\CustomRouteContext;
use AlexFigures\Symfony\CustomRoute\Handler\CustomRouteHandlerInterface;
use AlexFigures\Symfony\CustomRoute\Result\CustomRouteResult;
#[NoTransaction]
final class CategoryArticlesHandler implements CustomRouteHandlerInterface
{
public function handle(CustomRouteContext $context): CustomRouteResult
{
$categoryId = $context->getParam('categoryId');
// Build criteria with custom condition for categoryId
// This merges with any filters/sorting/pagination from query string
$criteria = $context->criteria()
->addCustomCondition(function ($qb) use ($categoryId) {
$qb->andWhere('e.category = :categoryId')
->setParameter('categoryId', $categoryId);
})
->build();
// Use repository to fetch collection with all criteria applied
// This automatically handles: filters, sorting, pagination, includes
$slice = $context->getRepository()->findCollection('articles', $criteria);
return CustomRouteResult::collection($slice->items, $slice->totalItems);
}
}Route Definition:
#[JsonApiResource(type: 'articles')]
#[JsonApiCustomRoute(
name: 'categories.articles',
path: '/categories/{categoryId}/articles',
methods: ['GET'],
handler: CategoryArticlesHandler::class,
resourceType: 'articles'
)]
class Article {}API Request:
GET /api/categories/123/articles?filter[status][eq]=published&sort=-createdAt&page[size]=10&page[number]=1This request will:
- Filter articles by
categoryId=123(from path) - Filter by
status=published(from query string) - Sort by
createdAtdescending - Return page 1 with 10 items per page
Add a simple filter condition. Supports all standard JSON:API operators:
$criteria = $context->criteria()
->addFilter('status', 'eq', 'published')
->addFilter('views', 'gte', 1000)
->addFilter('tags', 'in', ['php', 'symfony'])
->build();Supported operators:
eq- equalsne- not equalslt- less thanlte- less than or equalgt- greater thangte- greater than or equalin- in arraynin- not in arraylike- SQL LIKE patternilike- case-insensitive LIKE
Add complex conditions using a QueryBuilder modifier callback:
$criteria = $context->criteria()
->addCustomCondition(function ($qb) {
$qb->andWhere('e.publishedAt IS NOT NULL')
->andWhere('e.publishedAt <= :now')
->setParameter('now', new \DateTimeImmutable());
})
->build();Use cases for custom conditions:
- Subqueries
- Complex joins
- Database-specific functions
- OR conditions across multiple fields
- Filtering by associations
Build the final Criteria with all modifications applied:
$criteria = $context->criteria()
->addFilter('status', 'eq', 'published')
->addCustomCondition(function ($qb) use ($userId) {
$qb->andWhere('e.author = :userId')
->setParameter('userId', $userId);
})
->build();
$slice = $context->getRepository()->findCollection('articles', $criteria);<?php
namespace App\CustomRoute;
use AlexFigures\Symfony\CustomRoute\Attribute\NoTransaction;
use AlexFigures\Symfony\CustomRoute\Context\CustomRouteContext;
use AlexFigures\Symfony\CustomRoute\Handler\CustomRouteHandlerInterface;
use AlexFigures\Symfony\CustomRoute\Result\CustomRouteResult;
/**
* Get articles for a specific tenant with full JSON:API query support.
*/
#[NoTransaction]
final class TenantArticlesHandler implements CustomRouteHandlerInterface
{
public function handle(CustomRouteContext $context): CustomRouteResult
{
$tenantId = $context->getParam('tenantId');
// Verify tenant exists (business logic)
// ... tenant validation code ...
// Build criteria with tenant filter + all query string parameters
$criteria = $context->criteria()
->addCustomCondition(function ($qb) use ($tenantId) {
// Filter by tenant (from path parameter)
$qb->andWhere('e.tenant = :tenantId')
->setParameter('tenantId', $tenantId);
})
->build();
// Fetch collection with automatic:
// - Filtering (from query string + custom condition)
// - Sorting (from query string)
// - Pagination (from query string)
// - Includes (from query string)
$slice = $context->getRepository()->findCollection('articles', $criteria);
return CustomRouteResult::collection($slice->items, $slice->totalItems);
}
}Supported API Requests:
# Basic request
GET /api/tenants/123/articles
# With filtering
GET /api/tenants/123/articles?filter[status][eq]=published
# With sorting
GET /api/tenants/123/articles?sort=-createdAt,title
# With pagination
GET /api/tenants/123/articles?page[size]=20&page[number]=2
# Combined
GET /api/tenants/123/articles?filter[status][eq]=published&sort=-createdAt&page[size]=10&include=author- No Code Duplication: Reuse existing filtering/sorting/pagination logic
- Consistent API: All JSON:API query parameters work the same way
- Type Safety: CriteriaBuilder provides a type-safe API
- Flexibility: Combine standard filters with custom business logic
- Performance: Automatic query optimization and eager loading
- Use
addCustomConditionfor associations: Filtering by relationships requires special handling - Validate path parameters: Always verify that path parameters (like
categoryId) are valid - Use
#[NoTransaction]for read-only handlers: Improves performance - Return proper totals: Always pass
$slice->totalItemstoCustomRouteResult::collection() - Document expected query parameters: Use the
descriptionparameter in#[JsonApiCustomRoute]
Before (manual filtering):
public function handle(CustomRouteContext $context): CustomRouteResult
{
$categoryId = $context->getParam('categoryId');
// Manual query - ignores query string parameters!
$articles = $this->em->getRepository(Article::class)
->findBy(['category' => $categoryId]);
return CustomRouteResult::collection($articles);
}After (with CriteriaBuilder):
public function handle(CustomRouteContext $context): CustomRouteResult
{
$categoryId = $context->getParam('categoryId');
// Automatic filtering, sorting, pagination from query string
$criteria = $context->criteria()
->addCustomCondition(fn($qb) => $qb->andWhere('e.category = :cat')->setParameter('cat', $categoryId))
->build();
$slice = $context->getRepository()->findCollection('articles', $criteria);
return CustomRouteResult::collection($slice->items, $slice->totalItems);
}