Skip to content

robiningelbrecht/php-slim-skeleton

Repository files navigation

Event-driven Slim 4 Framework skeleton

Slim

CI Codecov.io License PHPStan Enabled PHP


An event-driven Slim 4 Framework skeleton using AMQP and CQRS

Installation

Default installation profile

The default installation profile has no examples. You should be using this profile if you know what's up and want to start with a clean slate.

> composer create-project robiningelbrecht/php-slim-skeleton [app-name] --no-install --ignore-platform-reqs --stability=dev
# Build docker containers
> docker-compose up -d --build
# Install dependencies
> docker-compose run --rm php-cli composer install

Full installation profile

The full installation profile has a complete working example.

> composer create-project robiningelbrecht/php-slim-skeleton:dev-master-with-examples [app-name] --no-install --ignore-platform-reqs --stability=dev
# Build docker containers
> docker-compose up -d --build
# Install dependencies
> docker-compose run --rm php-cli composer install
# Initialize example
> docker-compose run --rm php-cli composer example:init
# Start consuming the voting example queue
> docker-compose run --rm php-cli bin/console app:amqp:consume add-vote-command-queue

Some examples

Registering a new route

namespace App\Controller;

class UserOverviewRequestHandler
{
    public function __construct(
        private readonly UserOverviewRepository $userOverviewRepository,
    ) {
    }

    public function handle(
        ServerRequestInterface $request,
        ResponseInterface $response): ResponseInterface
    {
        $users = $this->userOverviewRepository->findonyBy(/*...*/);
        $response->getBody()->write(/*...*/);

        return $response;
    }
}

Head over to config/routes.php and add a route for your RequestHandler:

return function (App $app) {
    // Set default route strategy.
    $routeCollector = $app->getRouteCollector();
    $routeCollector->setDefaultInvocationStrategy(new RequestResponseArgs());
    
    $app->get('/user/overview', UserOverviewRequestHandler::class.':handle');
};

Full documentation

Console commands

The console application uses the Symfony console component to leverage CLI functionality.

#[AsCommand(name: 'app:user:create')]
class CreateUserConsoleCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // ...
        return Command::SUCCESS;
    }
}

Full documentation

Domain commands and command handlers

The skeleton allows you to use commands and command handlers to perform actions. These 2 always come in pairs, when creating a new command in the write model, a corresponding command handler has to be created as well.

Creating a new command

namespace App\Domain\WriteModel\User\CreateUser;

class CreateUser extends DomainCommand
{
 
}

Creating the corresponding command handler

namespace App\Domain\WriteModel\User\CreateUser;

#[AsCommandHandler]
class CreateUserCommandHandler implements CommandHandler
{
    public function __construct(
    ) {
    }

    public function handle(DomainCommand $command): void
    {
        assert($command instanceof CreateUser);

        // Do stuff.
    }
}

Full documentation

Eventing

The idea of this project is that everything is, or can be, event-driven. Event sourcing is not provided by default.

Create a new event

class UserWasCreated extends DomainEvent
{
    public function __construct(
        private UserId $userId,
    ) {
    }

    public function getUserId(): UserId
    {
        return $this->userId;
    }
}

Record the event

class User extends AggregateRoot
{
    private function __construct(
       private UserId $userId,
    ) {
    }

    public static function create(
        UserId $userId,
    ): self {
        $user = new self($userId);
        $user->recordThat(new UserWasCreated($userId));

        return $user;
    }
}

Publish the event

class UserRepository extends DbalAggregateRootRepository
{
    public function add(User $user): void
    {
        $this->connection->insert(/*...*/);
        $this->publishEvents($user->getRecordedEvents());
    }
}

Listen to the event

#[AsEventListener(type: EventListenerType::PROCESS_MANAGER)]
class UserNotificationManager extends ConventionBasedEventListener
{
   
    public function reactToUserWasCreated(UserWasCreated $event): void
    {
        // Send out some notifications.
    }
}

Full documentation

Async processing of commands with RabbitMQ

The chosen AMQP implementation for this project is RabbitMQ, but it can be easily switched to for example Amazon's AMQP solution.

Registering new queues

#[AsEventListener(type: EventListenerType::PROCESS_MANAGER)]
class UserCommandQueue extends CommandQueue
{
}

Queueing commands

class YourService
{
    public function __construct(
        private readonly UserCommandQueue $userCommandQueue
    ) {
    }

    public function aMethod(): void
    {
        $this->userCommandQueue->queue(new CreateUser(/*...*/));
    }
}

Consuming your queue

> docker-compose run --rm php-cli bin/console app:amqp:consume user-command-queue

Full documentation

Database migrations

To manage database migrations, the doctrine/migrations package is used.

#[Entity]
class User extends AggregateRoot
{
    private function __construct(
        #[Id, Column(type: 'string', unique: true, nullable: false)]
        private readonly UserId $userId,
        #[Column(type: 'string', nullable: false)]
        private readonly Name $name,
    ) {
    }

    // ...
}

You can have Doctrine generate a migration for you by comparing the current state of your database schema to the mapping information that is defined by using the ORM and then execute that migration.

> docker-compose run --rm php-cli vendor/bin/doctrine-migrations diff
> docker-compose run --rm php-cli vendor/bin/doctrine-migrations migrate

Full documentation

Templating engine

The template engine of choice for this project is Twig and can be used to render anything HTML related.

Create a template

<h1>Users</h1>
<ul>
    {% for user in users %}
        <li>{{ user.username|e }}</li>
    {% endfor %}
</ul>

Render the template

class UserOverviewRequestHandler
{
    public function __construct(
        private readonly Environment $twig,
    ) {
    }

    public function handle(
        ServerRequestInterface $request,
        ResponseInterface $response): ResponseInterface
    {
        $template = $this->twig->load('users.html.twig');
        $response->getBody()->write($template->render(/*...*/));

        return $response;
    }
}

Full documentation

Documentation

Learn more at these links:

Projects using this skeleton

Contributing

Please see CONTRIBUTING for details.