Application Builders

Basics

Application builders provide an easy way to configure your application's components, eg adding binders, routes, global middleware, console commands, validators, and more. Components are pieces of your application that are shared across modules (chunks of your domain). If you are running a site where users can buy books, you might have a user module, a book module, and a shopping cart module. Each of these modules will have separate binders, routes, console commands, etc. So, why not bundle all the configuration logic by module?

Let's look at an example of a module:

use Aphiria\Application\Builders\IApplicationBuilder;
use Aphiria\Application\IModule;
use Aphiria\Console\Commands\CommandRegistry;
use Aphiria\Framework\Application\AphiriaComponents;
use Aphiria\Routing\Builders\RouteCollectionBuilder;

class UserModule implements IModule
{
    // Gives us a fluent way to configure Aphiria components
    use AphiriaComponents;

    public function build(IApplicationBuilder $appBuilder): void
    {
        $this->withBinders($appBuilder, [new UserBinder()])
            ->withRoutes($appBuilder, function (RouteCollectionBuilder $routes) {
                $routes->get('users/:id')
                    ->mapsToMethod(UserController::class, 'getUserById');
            })
            ->withCommands($appBuilder, function (CommandRegistry $commands) {
                $commands->registerCommand(
                    new GenerateUserReportCommand(),
                    GenerateUserReportCommandHandler::class
                );
            });
    }
}

Here's the best part of how Aphiria is built - all components, even Aphiria-provided components for things like binders, routes, console commands, etc, are not first-class citizens. They're just normal components, which means it's trivial to configure another library in place of any Aphiria libraries if you so choose.

Modules

To register a module, you can use the AphiriaComponents trait:

use Aphiria\Application\Builders\IApplicationBuilder;
use Aphiria\Framework\Application\AphiriaComponents;

class App
{
    use AphiriaComponents;

    public function build(IApplicationBuilder $appBuilder): void
    {
        $this->withModules($appBuilder, new MyModule());

        // Or register many modules

        $this->withModules($appBuilder, [new MyModule1(), new MyModule2()]);
    }
}

Components

A component is a piece of your application that is shared across domains. Below, we'll go over the components that are bundled with Aphiria, and some decoration methods to help configure them.

Binders

You can configure your module to require binders.

use Aphiria\Application\Builders\IApplicationBuilder;
use Aphiria\Application\IModule;
use Aphiria\Framework\Application\AphiriaComponents;

class UserModule implements IModule
{
    use AphiriaComponents;

    public function build(IApplicationBuilder $appBuilder): void
    {
        // Add a binder
        $this->withBinders($appBuilder, new UserBinder());

        // Or use an array of binders

        $this->withBinders($appBuilder, [new UserBinder()]);
    }
}

You can also configure your app to use a particular binder dispatcher:

use Aphiria\Application\Builders\IApplicationBuilder;
use Aphiria\Application\IModule;
use Aphiria\Configuration\GlobalConfiguration;
use Aphiria\DependencyInjection\Binders\LazyBinderDispatcher;
use Aphiria\DependencyInjection\Binders\Metadata\Caching\FileBinderMetadataCollectionCache;
use Aphiria\Framework\Application\AphiriaComponents;

class App implements IModule
{
    use AphiriaComponents;

    public function build(IApplicationBuilder $appBuilder): void
    {
        if (\getenv('APP_ENV') === 'production') {
            $cachePath = GlobalConfiguration::getString('aphiria.binders.metadataCachePath');
            $cache = new FileBinderMetadataCollectionCache($cachePath);
        } else {
            $cache = null;
        }

        $this->withBinderDispatcher($appBuilder, new LazyBinderDispatcher($cache));
    }
}

Routes

You can register routes for your module, and you can enable route annotations.

use Aphiria\Application\Builders\IApplicationBuilder;
use Aphiria\Application\IModule;
use Aphiria\Framework\Application\AphiriaComponents;
use Aphiria\Routing\Builders\RouteCollectionBuilder;

class UserModule implements IModule
{
    use AphiriaComponents;

    public function build(IApplicationBuilder $appBuilder): void
    {
        // Add some routes
        $this->withRoutes($appBuilder, function (RouteCollectionBuilder $routes) {
            $routes->get('users/:id')
                ->mapsToMethod(UserController::class, 'getUserById');
        });

        // Enable route annotations
        $this->withRouteAnnotations($appBuilder);
    }
}

Middleware

Some modules might need to add global middleware to your application.

use Aphiria\Application\Builders\IApplicationBuilder;
use Aphiria\Application\IModule;
use Aphiria\Framework\Application\AphiriaComponents;
use Aphiria\Middleware\MiddlewareBinding;

class UserModule implements IModule
{
    use AphiriaComponents;

    public function build(IApplicationBuilder $appBuilder): void
    {
        // Add global middleware (executed before each route)
        $this->withGlobalMiddleware($appBuilder, new MiddlewareBinding(Cors::class));

        // Or use an array of bindings
        $this->withGlobalMiddleware($appBuilder, [new MiddlewareBinding(Cors::class)]);

        // Or with a priority (lower number = higher priority)
        $this->withGlobalMiddleware($appBuilder, new MiddlewareBinding(Cors::class), 1);
    }
}

Console Commands

You can register console commands, and enable command annotations from your modules.

use Aphiria\Application\Builders\IApplicationBuilder;
use Aphiria\Application\IModule;
use Aphiria\Console\Commands\CommandRegistry;
use Aphiria\Framework\Application\AphiriaComponents;

class UserModule implements IModule
{
    use AphiriaComponents;

    public function build(IApplicationBuilder $appBuilder): void
    {
        // Add console commands
        $this->withCommands($appBuilder, function (CommandRegistry $commands) {
            $commands->registerCommand(
                new GenerateUserReportCommand(),
                GenerateUserReportCommandHandler::class
            );
        });

        // Enable command annotations
        $this->withCommandAnnotations($appBuilder);

        // Register built-in framework commands
        $this->withFrameworkCommands($appBuilder);

        // Register built-in framework commands, but exclude certain ones
        $this->withFrameworkCommands($appBuilder, ['app:serve']);
    }
}

Validator

You can also configure constraints for your models and enable validator annotations.

use Aphiria\Application\Builders\IApplicationBuilder;
use Aphiria\Application\IModule;
use Aphiria\Framework\Application\AphiriaComponents;
use Aphiria\Validation\Builders\ObjectConstraintsRegistryBuilder;
use Aphiria\Validation\Constraints\EmailConstraint;

class UserModule implements IModule
{
    use AphiriaComponents;

    public function build(IApplicationBuilder $appBuilder): void
    {
        // Add constraints to a class
        $this->withObjectConstraints($appBuilder, function (ObjectConstraintsRegistryBuilder $objectConstraintsBuilder) {
            $objectConstraintsBuilder->class(User::class)
                ->hasPropertyConstraints('email', new EmailConstraint());
        });

        // Enable validator annotations
        $this->withValidatorAnnotations($appBuilder);
    }
}

Serializer

Your modules can configure custom encoders for your models.

use Aphiria\Application\Builders\IApplicationBuilder;
use Aphiria\Application\IModule;
use Aphiria\Framework\Application\AphiriaComponents;
use Aphiria\Serialization\Encoding\EncodingContext;
use Aphiria\Serialization\Encoding\IEncoder;

class UserModule implements IModule
{
    use AphiriaComponents;

    public function build(IApplicationBuilder $appBuilder): void
    {
        // Add an encoder to simplify serializing an object
        $this->withEncoder($appBuilder, User::class, new class() implements IEncoder {
            public function decode($userHash, string $type, EncodingContext $context): User
            {
                return new User($userHash['id'], $userHash['email']);
            }

            public function encode($user, EncodingContext $context)
            {
                return ['id' => $user->id, 'email' => $user->email];
            }
        });
    }
}

Exception Handler

Exceptions may be mapped to custom HTTP responses and PSR-3 log levels.

use Aphiria\Application\Builders\IApplicationBuilder;
use Aphiria\Application\IModule;
use Aphiria\Console\Output\IOutput;
use Aphiria\Console\StatusCodes;
use Aphiria\Framework\Application\AphiriaComponents;
use Aphiria\Net\Http\HttpStatusCodes;
use Aphiria\Net\Http\IRequest;
use Aphiria\Net\Http\IResponseFactory;
use Psr\Log\LogLevel;

class UserModule implements IModule
{
    use AphiriaComponents;

    public function build(IApplicationBuilder $appBuilder): void
    {
        // Add a custom HTTP response for an exception
        $this->withHttpExceptionResponseFactory(
            $appBuilder,
            UserNotFoundException::class,
            function (UserNotFoundException $ex, IRequest $request, IResponseFactory $responseFactory) {
                return $responseFactory->createResponse($request, HttpStatusCodes::HTTP_NOT_FOUND);
            }
        );

        // Add a custom console output writer for an exception
        $this->withConsoleExceptionOutputWriter(
            $appBuilder,
            UserNotFoundException::class,
            function (UserNotFoundException $ex, IOutput $output) {
                $output->writeln('Missing user');

                return StatusCodes::FATAL;
            }
        );

        // Add a custom PSR-3 log level for an exception
        $this->withLogLevelFactory(
            $appBuilder,
            UserCorruptedException::class,
            fn (UserCorruptedException $ex) => LogLevel::CRITICAL
        );
    }
}

Adding Custom Components

You can add your own custom components to application builders. They typically have with*() methods to let you configure the component, and a build() method (called internally) that actually finishes building the component.

Note: Binders aren't dispatched until just before build() is called on the components. This means you can't inject dependencies from binders into your components - they won't have been bound yet. So, if you need any dependencies inside the build() method, use the DI container to resolve them.

Let's say you prefer to use Symfony's router, and want to be able to add routes from your modules. This requires a few simple steps:

  1. Create a binder for the Symfony services
  2. Create a component to let you add routes from modules
  3. Register the binder and component to your app
  4. Start using the component

First, let's create a binder for the router so that the DI container can resolve it:

use Aphiria\DependencyInjection\Binders\Binder;
use Aphiria\DependencyInjection\IContainer;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\Matcher\UrlMatcherInterface;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouteCollection;

class SymfonyRouterBinder extends Binder
{
    public function bind(IContainer $container): void
    {
        $routes = new RouteCollection();
        $requestContext = new RequestContext(/* ... */);
        $matcher = new UrlMatcher($routes, $requestContext);

        $container->bindInstance(RouteCollection::class, $routes);
        $container->bindInstance(UrlMatcherInterface ::class, $matcher);
    }
}

Next, let's define a component to let us add routes.

use Aphiria\Api\Application;
use Aphiria\Application\IComponent;
use Aphiria\DependencyInjection\IContainer;
use Aphiria\Net\Http\Handlers\IRequestHandler;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

class SymfonyRouterComponent implements IComponent
{
    private IContainer $container;
    private array $routes = [];

    public function __construct(IContainer $container)
    {
        $this->container = $container;
    }

    public function build(): void
    {
        $routes = $this->container->resolve(RouteCollection::class);

        foreach ($this->routes as $name => $route) {
            $routes->add($name, $route);
        }

        // Assume we've created a request handler that uses the Symfony route matcher
        $this->container->for(Application::class, function (IContainer $container) {
            $container->bindInstance(IRequestHandler::class, new SymfonyRouterRequestHandler());
        });
    }

    // Our own method for adding routes
    public function withRoutes(string $name, Route $route): self
    {
        $this->routes[$name] = $route;

        return $this;
    }
}

Let's register the binder and component to our app:

use Aphiria\Application\Builders\IApplicationBuilder;
use Aphiria\Application\IModule;
use Aphiria\DependencyInjection\IContainer;
use Aphiria\Framework\Application\AphiriaComponents;

class App implements IModule
{
    use AphiriaComponents;

    private IContainer $container;

    public function __construct(IContainer $container)
    {
        $this->container = $container;
    }

    public function build(IApplicationBuilder $appBuilder): void
    {
        $this->withComponent($appBuilder, new SymfonyRouterComponent($this->container))
            ->withBinders($appBuilder, new SymfonyRouterBinder());
    }
}

All that's left is to start using it from a module:

use Aphiria\Application\IModule;
use Aphiria\Application\Builders\IApplicationBuilder;
use Symfony\Component\Routing\Route;

class MyModule implements IModule
{
    public function build(IApplicationBuilder $appBuilder): void
    {
        $appBuilder->getComponent(SymfonyRouterComponent::class)
            ->withRoutes('GetUserById', new Route('users/{id}'));
    }
}