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:

  1. 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'
  1. Register controllers in config/services.yaml:
App\NewContext\Infrastructure\Http\:
    resource: '../src/NewContext/Infrastructure/Http/'
    tags: ['controller.service_arguments']
  1. Add routes in config/routes/api.yaml.
  2. 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

  1. Edit config/packages/stripe.yaml — see Configuration > Stripe Plans for the full format and examples.
  2. Add the plan type to src/Subscription/Domain/Model/PlanType.php:
case Enterprise = 'enterprise';
  1. Define its features in the features() method.
  2. Sync to Stripe:
docker compose exec php bin/console app:stripe:sync-plans
  1. Create a migration if the plan_type database 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.