Customization
How to extend the boilerplate with new features
Customization
This guide shows how to extend the boilerplate with new features.
Adding a New Command (Write Operation)
Example: adding an "Update Profile" feature.
1. Create the Feature Folder
src/UserManagement/Application/UpdateProfile/
2. Create the Command DTO
// src/UserManagement/Application/UpdateProfile/UpdateProfileCommand.php
namespace App\UserManagement\Application\UpdateProfile;
final readonly class UpdateProfileCommand
{
public function __construct(
public string $userId,
public string $name,
) {}
}
3. Create the Handler
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UpdateProfileHandler
{
public function __construct(
private UserRepositoryInterface $userRepository,
) {}
public function __invoke(UpdateProfileCommand $command): void
{
$user = $this->userRepository->get(UserId::fromString($command->userId));
$user->updateName($command->name);
$this->userRepository->save($user);
}
}
4. Create the Controller
// src/UserManagement/Infrastructure/Http/UpdateProfileController.php
namespace App\UserManagement\Infrastructure\Http;
use App\UserManagement\Application\UpdateProfile\UpdateProfileCommand;
use App\UserManagement\Infrastructure\Security\SecurityUser;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
final readonly class UpdateProfileController
{
public function __construct(
private MessageBusInterface $commandBus,
) {}
public function __invoke(#[CurrentUser] SecurityUser $user, Request $request): JsonResponse
{
$command = new UpdateProfileCommand(
userId: $user->getUserIdentifier(),
name: $request->getPayload()->getString('name'),
);
$this->commandBus->dispatch($command);
return new JsonResponse(['message' => 'Profile updated'], Response::HTTP_OK);
}
}
5. Add the Route
In config/routes/api.yaml:
api_update_profile:
path: /api/v1/user/profile
methods: [PUT]
controller: App\UserManagement\Infrastructure\Http\UpdateProfileController
6. Write Tests
Create both a unit test (with fakes) and a functional test (with HTTP requests).
Adding a New Query (Read Operation)
Example: adding a "List Users" admin query.
1. Create the Query and ViewModel
// src/UserManagement/Application/ListUsers/ListUsersQuery.php
final readonly class ListUsersQuery
{
public function __construct(
public int $page = 1,
public int $limit = 20,
) {}
}
// src/UserManagement/Application/ListUsers/UserListViewModel.php
final readonly class UserListViewModel
{
public function __construct(
public array $users,
public int $total,
) {}
}
2. Create the Read Model Repository Interface
// src/UserManagement/Application/ListUsers/UserListReadModelRepositoryInterface.php
interface UserListReadModelRepositoryInterface
{
public function findPaginated(int $page, int $limit): UserListViewModel;
}
3. Implement with DBAL
// src/UserManagement/Infrastructure/Persistence/ReadModel/DbalUserListReadModelRepository.php
final readonly class DbalUserListReadModelRepository implements UserListReadModelRepositoryInterface
{
public function __construct(private Connection $connection) {}
public function findPaginated(int $page, int $limit): UserListViewModel
{
// Use raw DBAL queries — no ORM
$sql = 'SELECT id, name, email, created_at FROM users LIMIT :limit OFFSET :offset';
// ...
}
}
4. Create the Handler
#[AsMessageHandler(bus: 'query.bus')]
final readonly class ListUsersHandler
{
public function __construct(
private UserListReadModelRepositoryInterface $repository,
) {}
public function __invoke(ListUsersQuery $query): UserListViewModel
{
return $this->repository->findPaginated($query->page, $query->limit);
}
}
5. Wire Up
Add the repository binding in config/services.yaml and create a controller + route.
Adding a New Bounded Context
src/NewContext/
├── Domain/
│ ├── Model/
│ ├── Port/
│ ├── Event/
│ └── Exception/
├── Application/
└── Infrastructure/
├── Http/
├── Persistence/
│ ├── Mapping/
│ └── ReadModel/
└── Console/
Then configure:
- Add ORM mapping in
config/packages/doctrine.yaml:
NewContext:
type: xml
dir: '%kernel.project_dir%/src/NewContext/Infrastructure/Persistence/Mapping'
prefix: 'App\NewContext\Domain\Model'
- Register controllers in
config/services.yaml:
App\NewContext\Infrastructure\Http\:
resource: '../src/NewContext/Infrastructure/Http/'
tags: ['controller.service_arguments']
- Add routes in
config/routes/api.yaml. - Create a migration for any new database tables.
Adding a New Domain Event
1. Create the Event
// src/{Context}/Domain/Event/OrderPlaced.php
final readonly class OrderPlaced
{
public function __construct(
public string $orderId,
public string $userId,
) {}
}
2. Dispatch from a Handler
$this->eventBus->dispatch(new OrderPlaced($order->id(), $userId));
3. Create a Listener
#[AsMessageHandler(bus: 'event.bus')]
final readonly class SendOrderConfirmationHandler
{
public function __invoke(OrderPlaced $event): void
{
// Send email, update stats, etc.
}
}
4. Route to Async (Optional)
In config/packages/messenger.yaml:
routing:
'App\NewContext\Domain\Event\OrderPlaced': async
Adding a New Email Template
1. Create the Template in Brevo
Create a new transactional email template in your Brevo dashboard. Note the template ID.
2. Add the Template ID to Config
In config/packages/brevo.yaml:
parameters:
brevo_template_order_confirmation: 5 # your new template ID
3. Add to the EmailSender Interface
Add a new method to src/UserManagement/Domain/Port/EmailSenderInterface.php:
public function sendOrderConfirmationEmail(string $email, string $userName, string $orderId): void;
4. Implement in Both Adapters
Brevo adapter (src/UserManagement/Infrastructure/Email/BrevoTransactionalEmailSender.php):
public function sendOrderConfirmationEmail(string $email, string $userName, string $orderId): void
{
$this->sendTemplateEmail($email, $this->orderConfirmationTemplateId, [
'userName' => $userName,
'orderId' => $orderId,
]);
}
Symfony Mailer adapter (src/UserManagement/Infrastructure/Email/SymfonyMailerEmailSender.php):
public function sendOrderConfirmationEmail(string $email, string $userName, string $orderId): void
{
$this->sendEmail($email, 'Order Confirmation', "Hi $userName, your order $orderId has been placed.");
}
Adding a New Stripe Plan
- Edit
config/packages/stripe.yaml— see Configuration > Stripe Plans for the full format and examples. - Add the plan type to
src/Subscription/Domain/Model/PlanType.php:
case Enterprise = 'enterprise';
- Define its features in the
features()method. - Sync to Stripe:
docker compose exec php bin/console app:stripe:sync-plans
- Create a migration if the
plan_typedatabase type needs updating.
Redis
Redis is provisioned but not active at runtime. The Docker container, PHP extension, and client library are all in place — messenger and cache currently use synchronous/in-memory alternatives (sync:// transport, APCu cache). Enable Redis when you need async processing or multi-server deployments.
Async Event Processing (Messenger)
In config/packages/messenger.yaml, switch the transport DSN:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%' # uncomment this line
#dsn: 'sync://' # comment this line
Set the Redis transport in .env.local:
MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
Start a worker to consume messages:
docker compose exec php bin/console messenger:consume async
In production, run the worker as a supervised process (systemd, Supervisor, or a separate container).
Distributed Caching
Switch cache pools from APCu to Redis in config/packages/cache.yaml:
framework:
cache:
app: cache.adapter.redis
default_redis_provider: 'redis://redis:6379'
This is required for multi-server deployments where APCu (per-process) won't share state across instances. Redis can also back rate limiting (framework.rate_limiter) in the same scenario.