Intent Bundle

Intent bundle integrates the Intent component into Symfony applications by providing a service for storing objects which have to be preserved between two stateless requests.

Installation

You can install the bundle using Composer:

composer require runopencode/intent-bundle

Depending on the driver which you intend to use, doctrine/dbal is required for storing intents within a database table, while doctrine/orm is required if you want the storage table to be generated along with the rest of your schema. Both are, most commonly, already a part of your project.

And then enable the bundle in your Symfony application:

1<?php
2
3// config/bundles.php
4
5return [
6    // ...
7    RunOpenCode\Bundle\IntentBundle\IntentBundle::class => ['all' => true],
8];

Configuration

The bundle allows you to choose the driver which is used for storing intents, and to configure it. By default, intents are stored within a database table by using the default Doctrine Dbal connection.

 1<?php
 2// config/packages/runopencode_intent.php
 3use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
 4
 5return static function (ContainerConfigurator $container): void {
 6    $container->extension('runopencode_intent', [
 7        'driver' => 'dbal',
 8        'dbal'   => [
 9            'connection' => 'Doctrine\DBAL\Connection',
10            'table_name' => 'runopencode_intent',
11        ],
12    ]);
13};
1# config/packages/runopencode_intent.yaml
2runopencode_intent:
3    driver: dbal
4    dbal:
5        connection: 'Doctrine\DBAL\Connection'
6        table_name: runopencode_intent

Configuration options

  • driver (enum, default: dbal): driver used to persist intents, either dbal or cache. Only the configuration of the chosen driver is used, while the other one is ignored.

  • dbal.connection (string, default: Doctrine\DBAL\Connection): service id of the connection within which intents are stored. By default, the default connection is used. If you have more than one connection, provide the service id of the connection of your choice, in example doctrine.dbal.reporting_connection.

  • dbal.table_name (string, default: runopencode_intent): name of the table within which intents are stored.

  • cache.pool (string, default: cache.app): service id of the PSR-6 cache pool within which intents are stored.

Storing intents within a cache pool

If you would rather store intents within Redis, Memcached, or any other cache implementation, configure the cache driver. It is advisable to use a dedicated cache pool for that purpose, so that clearing an application cache does not remove pending intents as well.

 1<?php
 2// config/packages/runopencode_intent.php
 3use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
 4
 5return static function (ContainerConfigurator $container): void {
 6    $container->extension('framework', [
 7        'cache' => [
 8            'pools' => [
 9                'app.cache_pool.intent' => [
10                    'adapter' => 'cache.adapter.redis',
11                ],
12            ],
13        ],
14    ]);
15
16    $container->extension('runopencode_intent', [
17        'driver' => 'cache',
18        'cache'  => [
19            'pool' => 'app.cache_pool.intent',
20        ],
21    ]);
22};
 1# config/packages/cache.yaml
 2framework:
 3    cache:
 4        pools:
 5            app.cache_pool.intent:
 6                adapter: cache.adapter.redis
 7
 8# config/packages/runopencode_intent.yaml
 9runopencode_intent:
10    driver: cache
11    cache:
12        pool: app.cache_pool.intent

Do note that a cache pool is a cache, and that it is allowed to evict an item before it expires. If losing an intent is not acceptable for your use case, use the dbal driver instead.

Database schema

Identifier of an intent is stored as ULID. Bundle registers that Doctrine type for you, so there is no need to add it to the doctrine.dbal.types configuration on your own.

When the dbal driver is used, the storage registers itself as a listener for the Doctrine ORM postGenerateSchema event. Table within which intents are stored is, therefore, a part of your schema and it will be generated for you:

bin/console doctrine:schema:update --dump-sql

If you are using Doctrine migrations, table will be a part of a generated migration as well:

bin/console doctrine:migrations:diff

Note that Doctrine ORM is required for this convenience only. If your project uses Doctrine Dbal without ORM, everything else works as expected, but you will have to create the table on your own.

Usage

Once the bundle is installed and configured, the storage is automatically available for dependency injection. Inject it using the interface:

 1<?php
 2
 3declare(strict_types=1);
 4
 5namespace App\Security;
 6
 7use App\Security\Intent\ResetPassword;
 8use RunOpenCode\Component\Intent\Contract\IntentStorageInterface;
 9use RunOpenCode\Component\Intent\Exception\NotExistsException;
10use Symfony\Component\Uid\Ulid;
11
12final readonly class PasswordResetService
13{
14    public function __construct(private IntentStorageInterface $storage)
15    {
16        // noop.
17    }
18
19    public function request(User $user): string
20    {
21        // Intent is valid for one hour only.
22        $identifier = $this->storage->store(new ResetPassword($user->getId()), 3600);
23
24        return (string)$identifier;
25    }
26
27    public function complete(string $token, string $password): void
28    {
29        try {
30            /** @var ResetPassword $intent */
31            $intent = $this->storage->fetch(Ulid::fromString($token));
32        } catch (NotExistsException) {
33            // Link is invalid, expired, or it has been used already.
34            // ...
35        }
36
37        // ...
38    }
39}

Read more about how to store and fetch intents in the Intent component documentation.

Removing expired intents

Expired intents are removed from the storage when they are fetched, which is not enough if a link is never clicked. For that purpose, a console command is provided:

bin/console runopencode:intent:maintenance

Do note that this command does nothing when the cache driver is used, since cache pools evict expired items on their own.