Docs / Services and plugins

Services and plugins

On this page

Introduction

A service is an object your application shares: a mailer, a payment client, a repository. The container makes services and hands them to the classes that ask for them. A plugin is where you tell the container how a service is made.

You rarely call the container yourself. You declare what a class needs in its constructor, and the container supplies it.

The container

Asking for a service

Type-hint it in a constructor. This works in actions, middleware, commands, jobs, listeners and other services, because the container makes them all:

use Crab\Contracts\Database\Database;

final readonly class SendInvoice
{
    public function __construct(private Database $db, private Mailer $mailer)
    {
    }
}

A class with no binding is made from its constructor's types, so most classes need no registration at all.

Binding a service

Bind when a class needs something the container cannot work out, such as a value from configuration:

// app/Setup.php
namespace App;

use Crab\Contracts\Container\Container;
use Crab\Contracts\Plugin;

final class Setup implements Plugin
{
    public function register(Container $container): void
    {
        $container->singleton(SmtpMailer::class, static fn (): SmtpMailer => new SmtpMailer(
            config('mail.host'),
            config('mail.port'),
        ));
    }
}
Method Gives Use for
singleton() One object per worker, made on first use Almost everything
bind() A new object on every request for it An object that carries the state of one piece of work
instance() An object that already exists A value made while the application is defined

The closure runs later, inside a worker, the first time the service is asked for. So register() only declares; it opens no connection and reads no file.

Binding an interface

Depend on an interface, and say once which class stands behind it:

$container->singleton(Mailer::class, SmtpMailer::class);

An action that asks for Mailer now receives the SmtpMailer, and a test can bind a fake under the same name.

Core and optional plugins

The framework's plugins are of two kinds.

Core plugins are on in every application made with App::configure(), without being listed: DatabasePlugin, AuthPlugin, CachePlugin, StoragePlugin, EventsPlugin and HttpClientPlugin, the list App::PLUGINS. Each binds on first use, so one you never touch opens nothing.

Optional plugins are off until you list them in app.php, because not every API wants them: QueuePlugin, SchedulePlugin, BroadcastPlugin and TokenAuthPlugin, the list App::OPTIONAL_PLUGINS. Packages add plugins of their own, listed the same way: crabphp/runs' after QueuePlugin, whose workers carry runs out, and crabphp/actors' after BroadcastPlugin, whose carrier actors travel on.

// app.php
use App\Setup;
use Crab\Queue\QueuePlugin;
use Crab\Schedule\SchedulePlugin;

return App::configure(__DIR__)
    ->withPlugins([
        QueuePlugin::class,
        SchedulePlugin::class,
    ])
    ->withSetup(Setup::class)
    ->create();

A package's plugin, such as RedisPlugin, is listed the same way, after the framework's it builds on.

Your application's setup

Your own services, listeners and scheduled tasks are declared in one class, app/Setup.php, given to withSetup():

// app.php
use App\Setup;

->withSetup(Setup::class)

It is a plugin in all but name: a class implementing Crab\Contracts\Plugin, whose register() runs once as the application is defined. It is registered after every plugin, whatever the order of the calls in app.php, so the services it builds on, such as the schedule's, are there. Split it when it grows, by area, and give withSetup() the list: ->withSetup([Setup::class, Billing::class]).

App::configure() starts with the core plugins, App::PLUGINS. Optional ones and those of the packages you use come after them, in the order given, then your setup. There is no boot() step: because bindings are closures that run on first use, by then every plugin has registered, and order rarely matters. It matters only when a plugin adds to something another made in register(), such as scheduled tasks or event listeners; put the plugin that owns the list first.

To leave out a core plugin or replace the core list, use one of these two forms:

// app.php — choose one
use Crab\Cache\CachePlugin;
use Crab\Database\DatabasePlugin;

->withPlugins(remove: [CachePlugin::class])             // without one of the core plugins
->withPlugins(only: [DatabasePlugin::class])            // these in place of the core plugins

remove: accepts only classes in App::PLUGINS and names the list when you give another. only: [] leaves out all core plugins. only: and remove: cannot be combined, even across calls. Plugins given as the first argument are still added after the replacement list. Calling withPlugins() again adds plugins; another only: replaces the previous replacement.

A framework plugin, core or optional, named again by class or as an object is registered once at its first place. Every package, including crabphp/redis and crabphp/clockwerk, is opt-in. Nothing is found by convention. new App() used directly registers no plugins.

Writing a plugin

A plugin for others to install is the same kind of class, shipped in a package: one method, register(), and the application lists it in withPlugins().

Settings with defaults

A plugin that ships in a package declares its settings, their defaults and what each takes, once. The application's own configuration wins, and config('mailer.port') then reads the same value everywhere:

use Crab\Contracts\Config\Config;

$container->get(Config::class)->defaults('mailer', [
    'host' => '127.0.0.1',
    'port' => 25,
    'timeout' => 10.0,
], [
    'host' => ['type' => 'string', 'minLength' => 1],
    'port' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 65_535],
    'timeout' => ['type' => 'number', 'exclusiveMinimum' => 0],
]);

The rules are JSON Schema: type (string, integer, number, boolean, array for a list, object for keys by name, null, or several), enum, minimum, maximum, exclusiveMinimum, minLength, pattern, format (timezone), items, minItems, properties, additionalProperties, examples and description. They are checked with every other setting before the application serves, as Checked at startup describes, and a rule using any other keyword is refused when it is declared. With rules, the section takes no keys but these; give '*' => [] to let others through.

Check the section where its service is made too, so a command or a test that uses it meets the same message:

$container->singleton(Mailer::class, static function (Container $container): Mailer {
    $config = $container->get(Config::class);
    $config->check('mailer');

    return new SmtpMailer($config->get('mailer.host'), (int) $config->get('mailer.port'));
});

A plugin that offers one more choice for another section's setting adds it with an enum of its own, which joins the earlier choices: this is how crabphp/redis makes "redis" a value of cache.store.

$config->defaults('cache', [], ['store' => ['enum' => ['redis']]]);

Reaching the application from a plugin

A plugin does everything through contracts in the container, so a package never names a framework class:

To Ask for And call
Add routes Crab\Contracts\Routing\Router get(), post(), prefix(), middleware()
Add commands Crab\Contracts\Console\Console add()
Add middleware around every request Crab\Contracts\Http\MiddlewareStack add()
Listen for events Crab\Contracts\Events\Dispatcher listen()
Schedule tasks Crab\Contracts\Schedule\Schedule call(), command(), job()
Add a folder of migrations Crab\Contracts\Database\Migrator path()
use Crab\Contracts\Console\Console;
use Crab\Contracts\Routing\Router;

public function register(Container $container): void
{
    $container->get(Router::class)->get('/billing/status', ShowBillingStatus::class);
    $container->get(Console::class)->add('billing:sync', SyncBilling::class);
}

Work in each worker

A plugin that must open something in every worker, such as a connection kept for the worker's life or a timer, implements WorkerPlugin as well:

use Crab\Contracts\Container\Container;
use Crab\Contracts\WorkerPlugin;
use Crab\Engine\Process\Worker;

final readonly class MetricsPlugin implements WorkerPlugin
{
    public function __construct(private float $interval = 10.0)
    {
    }

    public function register(Container $container): void
    {
        $container->singleton(Metrics::class);
    }

    public function start(Worker $worker, Container $container): void
    {
        $metrics = $container->get(Metrics::class);
        $worker->loop->repeat($this->interval, static fn () => $metrics->flush());
    }

    public function stop(Worker $worker, Container $container): void
    {
        $container->get(Metrics::class)->flush();
    }
}

This plugin flushes every ten seconds by default; its constructor accepts a shorter interval for a test. start() runs in each worker after it is forked and before it takes requests; throwing there stops the server from starting. stop() runs after the worker has finished its requests, in reverse order of registration, and must not wait on anything. What a timer throws is logged and the worker keeps running.

[!WARNING] Never open a connection in register() or in a constructor. It would be opened once in the parent process and then shared by every forked worker, which corrupts it. Open on first use, or in start().

Worker processes of your own

The queue and the scheduler run in processes apart from the ones serving HTTP. A package that needs the same, such as a consumer of another message broker, asks for Crab\Contracts\Runner in a command:

use Crab\Contracts\Console\Command;
use Crab\Contracts\Console\Input;
use Crab\Contracts\Console\Output;
use Crab\Contracts\Runner;
use Crab\Engine\Async\Async;
use Crab\Engine\Async\Cancellation;
use Crab\Engine\Process\Worker;

final readonly class ConsumeEvents implements Command
{
    public function __construct(private Runner $runner, private Broker $broker)
    {
    }

    public function description(): string
    {
        return 'Take events from the broker until stopped';
    }

    public function handle(Input $input, Output $output): int
    {
        $broker = $this->broker;
        $this->runner->run('broker', static function (Worker $worker, Cancellation $cancellation) use ($broker): void {
            while (!$cancellation->isCancelled()) {
                $broker->handleNext();
                Async::sleep($worker->loop, 0.1);       // lets the worker hear that it should stop
            }
        }, workers: 2);

        return 0;
    }
}

Each worker is started and stopped as an HTTP worker is, with every WorkerPlugin around it and every failure logged. To run several groups under one parent process, as queue:work does for its supervisors, give runAll() a list of Crab\Contracts\Workers, each a group name, its work and its number of workers. The cancellation fires when the process is told to stop; finish what is in hand and return. The worker only hears it while your code waits through the engine, as Async::sleep() does, so a loop that never waits never stops.

A server of your own beside HTTP

start runs HTTP, and beside it the servers that are registered and switched on. The queue registers queue and the scheduler schedule. Register your own through Crab\Contracts\Servers, each under an id:

use Crab\Contracts\Servers;
use Crab\Contracts\Workers;
use Crab\Engine\Server\WebSocketServer;
use Crab\Engine\Crab;

public function register(Container $container): void
{
    $servers = $container->get(Servers::class);

    // One of your commands, run in a process of its own.
    $servers->command('prices', 'price:stream --exchange=nyse');

    // Any program.
    $servers->process('vite', 'npm run dev');

    // Workers of the server's own, here listening for WebSocket connections.
    $servers->add('chat', static fn (): array => [
        new Workers('chat', workers: 2, setup: static function (Crab $group) use ($container): void {
            $group->listen('tcp://0.0.0.0:8081', new WebSocketServer($container->get(ChatRoom::class)));
        }),
    ], runs: 'WebSocket on :8081');
}

price:stream is an application command. Validate --exchange against your configured exchange names before opening its feed; the console passes the value unchanged.

A program is run through the shell, so a pipeline or a && b works. When the server stops or reloads, every process the line started is sent SIGTERM and given a few seconds to finish, so a program should stay in the foreground and clean up on that signal.

start runs every server registered, beside HTTP. A plugin registers its own, so listing it in app.php is what runs it; one registered in your setup runs wherever the application starts. To run one only somewhere, register it only there, as in if (config('app.debug')) { $servers->process('vite', 'npm run dev'); }.

Method Runs When the server stops or reloads
command($id, 'price:stream --exchange=nyse') That command of yours, as if typed after php cua, in a process of its own It is sent SIGTERM, given time to finish, and started afresh
process($id, 'any program') The program, through the shell The same
add($id, fn () => [new Workers(...)]) Groups of workers inside the server itself They finish what they have and are replaced, as the HTTP workers are

What a command or a program prints appears in the server's output with its id in front, [vite] ready in 300 ms. One that ends is started again. Registering an id a second time takes the place of the first, so your plugin, which comes after the framework's, can say what queue runs.

[!WARNING] A server that listens on an address of its own cannot yet be run under start --daemon and reloaded: on reload only the HTTP socket is handed from the old server to the new. start refuses to start it there and says so. Keep it across reloads, as below, or run start in the foreground, kept alive by systemd or a container, where a restart replaces the whole process.

Keeping a server across reloads

Some work must not be cut short by a deploy: an agent in the middle of a long task, a process that owns a terminal. Give reloads: false to any of the three methods:

use Crab\Engine\Async\Cancellation;
use Crab\Engine\Process\Worker;

$servers->command('agent', 'agent:serve', reloads: false);

$servers->add('agent', static fn (): array => [
    new Workers('agent', work: static function (Worker $worker, Cancellation $cancellation): void {
        // Take work and carry it out until $cancellation fires.
    }),
], grace: 30.0, reloads: false);

Under start --daemon such a server runs in a process of its own beside the one that serves HTTP. reload gives HTTP and the other servers the new code and leaves this one running the code it started with; restart and stop end it with the rest. If it ends by itself it is started again, on the code as it is then. In the foreground, SIGUSR1 replaces every worker but its own.

A group given to add() has a setup, which has it listen on an address of its own, or work, a loop that runs until it is told to stop, or both. The closure that returns the groups runs when the server starts, so it may read the configuration and what later plugins bound.

Rules for a service

See Request lifecycle for why.