antoi / restify-bundle
Reusable Symfony bundle providing abstract REST CRUD services, entity hydration, and pick-based serialization.
Package info
github.com/antoine1003/restify-bundle
Type:symfony-bundle
pkg:composer/antoi/restify-bundle
Requires
- php: >=8.2
- doctrine/doctrine-bundle: ^2.11 || ^3.0
- doctrine/orm: ^2.17 || ^3.0
- symfony/dependency-injection: ^6.4 || ^7.0 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.0 || ^8.0
- symfony/property-access: ^6.4 || ^7.0 || ^8.0
- symfony/serializer: ^6.4 || ^7.0 || ^8.0
- symfony/validator: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- phpunit/phpunit: ^11.0
README
A reusable Symfony bundle providing a complete REST CRUD stack: abstract service, controller, repository, automatic entity hydration, and pick-based response enrichment.
Compatible with Symfony 6.4, 7.x, and 8.x — requires PHP 8.2+.
Installation
composer require antoi/restify-bundle
If you are not using Symfony Flex, register the bundle manually:
// config/bundles.php return [ Antoi\RestifyBundle\RestifyBundle::class => ['all' => true], ];
Full wiring example (User resource)
1. Repository
use Antoi\RestifyBundle\Repository\AbstractRestRepository; class UserRepository extends AbstractRestRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, User::class); } // Only these fields may be used as query-param filters protected function getFilterableFields(): array { return ['name', 'email', 'status', 'createdAt']; } // Eager-load these relations on every list query protected function getDefaultJoins(): array { return ['role']; } }
2. Service
use Antoi\RestifyBundle\Service\AbstractRestService; class UserService extends AbstractRestService { protected function getEntityClass(): string { return User::class; } protected function getWritableFields(): array { return ['name', 'email', 'role', 'status']; } }
Wire in config/services.yaml (needed because AbstractRestService takes a typed AbstractRestRepository):
App\Service\UserService: arguments: $repository: '@App\Repository\UserRepository'
3. Controller
use Antoi\RestifyBundle\Controller\AbstractRestController; #[Route('/api/users')] class UserController extends AbstractRestController { public function __construct( SerializerInterface $serializer, ValidatorInterface $validator, PickResolver $pickResolver, UserService $service, ) { parent::__construct($serializer, $validator, $pickResolver, $service); } protected function getReadGroups(): array { return ['user:read']; } protected function getListGroups(): array { return ['user:list']; } }
That's it. Six endpoints are registered automatically.
Endpoints
| Method | Path | Action | Description |
|---|---|---|---|
GET |
/ |
list() |
Paginated, filterable list |
GET |
/{id} |
show() |
Single resource |
POST |
/ |
create() |
Create + validate + persist |
PUT |
/{id} |
update() |
Full replace + validate + persist |
PATCH |
/{id} |
patch() |
Partial update + validate + save |
DELETE |
/{id} |
delete() |
Remove |
Query parameters
Pagination & sorting
GET /api/users?page=2&limit=10&sort=name,-createdAt
sort=name→ORDER BY name ASCsort=-createdAt→ORDER BY createdAt DESC- Multiple fields:
sort=name,-createdAt
Filtering
Any query param not in [page, limit, sort, pick] is forwarded to the repository as a filter.
GET /api/users?status=active&createdAt_gte=2024-01-01&name_like=john
Supported operators (append as suffix):
| Suffix | SQL equivalent |
|---|---|
| (none) | = :value |
_gte |
>= :value |
_gt |
> :value |
_lte |
<= :value |
_lt |
< :value |
_neq |
!= :value |
_like |
LIKE %value% |
_in |
IN (a, b, c) |
=null |
IS NULL |
Pick — extra properties outside serialization groups
GET /api/users/42?pick=ip,lastLogin.ip,roles.name
Enriches the serialized response with additional properties resolved from the entity:
ip→$user->getIp()lastLogin.ip→$user->getLastLogin()->getIp()(deep traversal)roles.name→[$role->getName(), ...]for each role in the collection
Response shapes
Single resource
{
"success": true,
"data": { "id": 1, "name": "John" }
}
Paginated list
{
"success": true,
"data": [ ... ],
"meta": { "total": 42, "page": 1, "limit": 20, "pages": 3 }
}
Validation error (422)
{
"success": false,
"message": "Validation failed.",
"errors": {
"email": ["This value is not a valid email address."]
}
}
Not found (404)
{ "success": false, "message": "User with ID \"99\" was not found." }
Wire
InvalidPayloadException(400) andResourceNotFoundException(404) to your API error listener to produce these shapes automatically.
Components reference
| Class | Namespace | Description |
|---|---|---|
AbstractRestRepository |
…\Repository |
Paginator, dynamic filters, operator suffixes |
AbstractRestService |
…\Service |
Decode → filter fields → hydrate → persist |
AbstractRestController |
…\Controller |
Full CRUD actions, serialization, pick merging |
EntityHydrator |
…\Service |
Scalar + datetime + ManyToOne + ManyToMany |
PickResolver |
…\Service |
Dot-notation deep property resolver |
ApiResponse |
…\DTO |
{ success, data, message } envelope |
PaginatedResponse |
…\DTO |
{ success, data, meta } envelope |
InvalidPayloadException |
…\Exception |
400 — bad JSON or wrong field type |
ResourceNotFoundException |
…\Exception |
404 — entity not found |