antoi/restify-bundle
Reusable Symfony bundle (6.4–8.x, PHP 8.2+) that provides a full REST CRUD stack: abstract repository/service/controller, automatic entity hydration, query filtering, eager-loading, and pick-based response enrichment. Six endpoints per resource, quickly wired.
Installation:
composer require antoi/restify-bundle
Register the bundle in config/bundles.php:
Antoi\RestifyBundle\RestifyBundle::class => ['all' => true],
First Use Case:
Create a basic User resource with these 3 files:
src/Repository/UserRepository.php):
use Antoi\RestifyBundle\Repository\AbstractRestRepository;
class UserRepository extends AbstractRestRepository {
public function __construct(ManagerRegistry $registry) {
parent::__construct($registry, User::class);
}
protected function getFilterableFields(): array { return ['name', 'email']; }
}
src/Service/UserService.php):
use Antoi\RestifyBundle\Service\AbstractRestService;
class UserService extends AbstractRestService {
protected function getEntityClass(): string { return User::class; }
protected function getWritableFields(): array { return ['name', 'email']; }
}
Wire in config/services.yaml:
App\Service\UserService:
arguments:
$repository: '@App\Repository\UserRepository'
src/Controller/UserController.php):
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']; }
}
Test Endpoints:
GET /api/users → Paginated list with filtering/sorting.POST /api/users → Create with validation.GET /api/users/1?pick=roles.name → Enriched response.getFilterableFields() and getDefaultJoins() for query customization.AbstractRestController and define serialization groups (getReadGroups()).getWritableFields().?pick= to dynamically add properties to responses (e.g., ?pick=lastLogin.ip).Controller → service->hydrate() → Validator → Repository → EntityManager.
Override UserService::create() or update() for custom logic (e.g., audit logs).Controller → service->list() → Repository->applyFilters() → Doctrine QueryBuilder.
Extend AbstractRestRepository for complex DQL.?status=active&createdAt_gte=2024-01-01 map to SQL via operator suffixes (_gte, _like).
getFilterableFields() to whitelist safe fields.password) from filtering.?sort=name,-createdAt → ORDER BY name ASC, createdAt DESC.
ORDER BY validation.getReadGroups() in the controller to scope serialized fields (e.g., ['user:read']).
#[Groups] on entity properties.?pick=ip,roles.name.
getIp() in User entity or extend PickResolver for custom logic.getWritableFields() using Symfony’s Validator.
#[Assert\Email] to User entity for field-level rules.UserService::validate() for cross-field logic (e.g., "email must match domain").?page=2&limit=10 → PaginatedResponse with meta object.
Repository->setDefaultLimit() or override Controller->paginate().AbstractRestRepository and override createQueryBuilder().SerializerInterface. Extend with custom normalizers for non-standard types.Validator. Add constraints via #[Assert\...] or override Service->validate().@IsGranted("ROLE_ADMIN") to controller methods.| Component | Extend/Override | Use Case |
|---|---|---|
| Repository | getFilterableFields() |
Whitelist safe filter fields. |
getDefaultJoins() |
Pre-load relations (e.g., ['role']). |
|
createQueryBuilder() |
Custom DQL for complex queries. | |
| Service | hydrate() |
Custom entity hydration logic. |
validate() |
Cross-field validation. | |
prePersist()/postPersist() |
Audit logs, soft deletes. | |
| Controller | list()/show()/create() etc. |
Custom actions (e.g., bulk operations). |
getReadGroups()/getListGroups() |
Dynamic serialization groups. | |
| PickResolver | resolve() |
Custom pick logic (e.g., computed fields). |
| ApiResponse | create() |
Custom response envelopes. |
#[Route('/api/users/bulk', methods: ['POST'])]
public function bulkCreate(Request $request) {
$data = json_decode($request->getContent(), true);
$users = $this->service->bulkHydrate($data);
$this->entityManager->persist($users);
$this->entityManager->flush();
return $this->json(['success' => true, 'data' => $users]);
}
// In UserService
protected function prePersist(User $entity, array $data): void {
if (isset($data['isDeleted']) && $data['isDeleted']) {
$entity->setDeletedAt(new \DateTime());
}
}
#[Route('/api/users/{id}/activate', methods: ['PATCH'])]
public function activate(User $user) {
$user->setStatus('active');
$this->entityManager->flush();
return $this->json(['success' => true]);
}
AbstractRestRepository and test hydration/validation.
$repository = $this->createMock(AbstractRestRepository::class);
$service = new UserService($repository);
$user = $service->hydrate(['name' => 'John']);
$this->assertInstanceOf(User::class, $user);
ApiTestCase to test endpoints with query params.
$client = static::createClient();
$client->request('GET', '/api/users?status=active&pick=roles.name');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['data' => [...], 'meta' => ['total' => 1]]);
PickResolver behavior.
$user = new User();
$user->setIp('192.168.1.1');
$resolver = new PickResolver();
$picks = $resolver->resolve($user, ['ip']);
$this->assertEquals('192.168.1.1', $picks['ip']);
getWritableFields() in the service and #[Groups] in the entity must align. Validation errors may occur if they diverge.How can I help you explore Laravel packages today?