Skip to content

Database

Amarant framework features a hybrid active record object relational mapper (ORM). Data is mapped using data models. Each data model is bound to a single database table.

The supported database type is PostgreSQL. See system requirements.

Database migrations are written using pure SQL for maximum flexibility.

Note

When referring to field or column, we always mean a colum in a database table.

Connections


Connections are abstractions that enable access to the database. Internally, each connection uses an adapter to communicate with the database. An adapter then actually connects to a database, for example using a PDO.

By default, the application has at least two connections configured, using the configuration mentioned here.

The first one, using the binding identifier db.connection.default represents the default connection while the second, db.connection.readonly is meant to be used as a read-only connection.

Note

Although by default, both of these connections use the same database, you can reconfigure them using a dependency injection configurator.

Creating a connection


If needed, you can create additional connections using dependency injection configurator.
Check the annotations to know more.

Vendor/ModuleName/Configuration/Di.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Configuration;

use Amarant\Framework\Contract\Di\BindInterface;
use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Contract\Di\DiConfiguratorInterface;
use Amarant\Framework\Data\Set;
use Amarant\Framework\Database\Adapter\PostgresqlAdapter;
use Amarant\Framework\Database\Connection\ConnectionPool;
use Amarant\Framework\Database\Connection\DefaultConnection;
use Amarant\Framework\Enum\DiEnum;
use Override;

class Di implements DiConfiguratorInterface
{
    #[Override] public function configure(ConfigurableContainerInterface $container, Set $configurators): void
    {
        $container
            ->bind(
                // (1)
                'db.adapter.custom',
                fn (ConfigurableContainerInterface $container) => $container
                    ->getBindBuilder()
                    // (2)
                    ->type(PostgresqlAdapter::class)
                    // (3)
                    ->argument('database', DiEnum::getEnvInjection('CUSTOM_CONNECTION_DATABASE_NAME'))
                    ->argument('dsn', DiEnum::getEnvInjection('CUSTOM_CONNECTION_DSN'))
                    // (4)
                    ->argument('options', [])
                    ->build()
            )
            ->bind(
                // (5)
                'db.connection.custom',
                fn (ConfigurableContainerInterface $container) => $container
                    ->getBindBuilder()
                    // (6)
                    ->type(DefaultConnection::class)
                    // (7)
                    ->argument('adapter', DiEnum::getIdInjection('db.adapter.custom'))
                    ->build()
            )
            ->extend(
                ConnectionPool::class,
                function (BindInterface $bind, ConfigurableContainerInterface $container) {
                    $bind->addValueToArgument(
                        // (8)
                        'connections',
                        DiEnum::getIdInjection('db.connection.custom'),
                        // (9)
                        'custom'
                    );

                    return $bind;
                }
            );
    }
}
  1. Create an adapter.
  2. The adapter implementation to use.
  3. Inject parameters from the environment.
  4. Optionally, add PDO options.
  5. Create a connection.
  6. The connection implementation to use.
  7. Inject the newly created adapter.
  8. Add connection to connection pool constructor parameter.
  9. The name of the new connection in the pool.

The connection can now be fetched from the pool like this:

$customConnection = $connectionPool->getConnection('custom');

Note

Connections will not establish a connection to the database in advance, but only when the connection is used to perform some query.

Data models


When data is loaded from the database, it's hydrated to a data model. Also, the data from a data model can be persisted to the database. Multiple hydrators and persisters can be used in this process.

When data models are loaded, saved or deleted, multiple events are fired, allowing event subscribers to access the data model, check what fields have changed, etc.

A data model can store data which is not a part of its table schema. By default, and when using only the default persister, only the properties that exist in the table schema will be persisted.

The reason for this is to allow multiple hydrators to enrich the data model with additional data. Conversely, to allow multiple persisters to save additional data, for example relational data to other tables.

Create a data model


Check the annotations to know more.

Vendor/ModuleName/DataModel/Example.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\DataModel;

use Amarant\Framework\Data\Database\DataModel;
use Override;

// (1)
/**
 * @method int|string|null getFieldA()
 * @method $this setFieldA(int|string|null $value)
 * @method int|string|null getFieldB()
 * @method $this setFieldB(int|string|null $value)
 */
// (2)
class Example extends DataModel
{
    // (3)
    public const FIELD_FIELD_A = 'field_a';
    public const FIELD_FIELD_B = 'field_b';

    // (4)
    #[Override] public static function getTable(): string
    {
        return 'example';
    }
}
  1. Add method annotations for the IDE.
  2. Extend the base data model Amarant\Framework\Data\Database\DataModel.
  3. It's recommended that you define table fields as public constants or an enum to be able to use them when building queries.
  4. This is the only method that must be implemented when extending the base data model. It should correspond to a table name in the database that this data model will be bound to.

To additionally customize the data model, override methods from the base model.

Note

When getting or setting data on a data model, always use get or set and the name of a field in PascalCase as the method name.

For example, a table field named some_field, its getter method is getSomeField, while the setter is setSomeField.

You can also use the is or has methods. The isSomeField would return the value of the field as boolean, while hasSomeField would return if the data model has a value present for the field, as a boolean.

Primary key


To set the primary key field name, override the method getIdField. By default, the name is id. To fetch the value of the primary key, use getIdValue method.

Discriminator


Defines a discriminator column (table field) name and a discriminator map which determine the data model class that the data will be hydrated into.

In the following example, when value of table field some_discriminator_field is value a, the table row will be hydrated to SomeDataModelClass::class.

Otherwise, when the value is value b, the table row will be hydrated using SomeOtherDataModelClass::class class.

Vendor/ModuleName/DataModel/Example.php
<?php

/**
 * @inheritDoc
 */
#[Override] public static function getDiscriminatorMap(): array
{
    return [
        'value a' => SomeDataModelClass::class,
        'value b' => SomeOtherDataModelClass::class,
    ];
}

#[Override] public static function getDiscriminatorColumn(): ?string
{
    return 'some_discriminator_field';
}

Default values

To set default data, override the method getDefaultData and return an array of field names mapped to a value. This data will be set on the data model upon instantiation.

Vendor/ModuleName/DataModel/Example.php
1
2
3
4
5
6
7
8
9
<?php

#[Override] protected function getDefaultData(): array
{
    return [
        'field_a' => 'a',
        'field_b' => 'b',
    ];
}

Tracking changes


A data model stores a copy of the data which can be used to check which values have changed.

Methods hasOriginalData, getOriginalData can be used to fetch the original data of the model.

Additionally, methods isModified and isDeleted can be used to check if the model was modified or deleted. These flags can be reset using clearModified or changed using setDeleted.

Duplication


To make a copy of a data model, use the copy method. This creates a new instance using the same data, but without the primary key field.

Important

This doesn't do a deep copy of any nested data models which might have been added to the model.

Events


All the following events are dispatched using
Amarant\Framework\Database\Event\DataModelEvent object
from Amarant\Framework\Database\Repository\AbstractRepository.

Important

When referring to these events, always use Amarant\Framework\Enum\EventEnum. Do not hardcode event names.

data_model_hydrate_after - dispatched after the data was fetched from the database and all hydrators were executed.

data_model_save_before , data_model_save_after - dispatched before and after all persisters are executed.

data_model_delete_before , data_model_delete_after - dispatched before and after the model was deleted.

data_model_delete_by_ids_before , data_model_delete_by_ids_after - dispatched before and after deleting rows using primary keys.

Note

If a transaction is retried, for example in case of a deadlock, each event won't be fired more than once.

Pausing events

By implementing Amarant\Framework\Contract\Database\DataModel\EventAwareDataModelInterface
on a data model, events can be paused.

If the method isPaused returns true, no events will be fired.

Repositories


The next thing we should do after creating a new data model is to create a repository for it.

Repositories expose a public interface the rest of the application can use to fetch, save and delete data for a particular data model.

Always use a default repository class, configured for a particular data model, and inject it into your repository, unless you have a good reason not to. The default repository takes care of events, hydration, persistence and more.

Creating a repository


First, create an interface for the repository. This will ease up testing, as you can swap to a different implementation. Also, you can make the implementation final.

Vendor/ModuleName/Contract/Database/Repository/ExampleRepositoryInterface.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Contract\Database\Repository;

use Vendor\ModuleName\DataModel\Example;

interface ExampleRepositoryInterface
{
    public function get(int|string $id): ?Example;

    public function save(Example $example): void;

    public function delete(Example $example): void;
}

Next, create the implementation.
Check the annotations to know more.

Vendor/ModuleName/DataModel/Repository/ExampleRepository.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\DataModel\Repository;

use Amarant\Framework\Contract\Database\Repository\RepositoryInterface;
use Override;
use Vendor\ModuleName\Contract\Database\Repository\ExampleRepositoryInterface;
use Vendor\ModuleName\DataModel\Example;

final class ExampleRepository implements ExampleRepositoryInterface
{
    public function __construct(
        // (1)
        private readonly RepositoryInterface $repository
    ) {
    }

    #[Override] public function get(int|string $id): ?Example
    {
        /**
         * @var Example|null $result
         */
        $result = $this->repository->get($id);
        return $result;
    }

    #[Override] public function save(Example $example): void
    {
        $this->repository->save($example);
    }

    #[Override] public function delete(Example $example): void
    {
        $this->repository->delete($example);
    }
}
  1. We'll inject this default repository in the next step.

Configure the repository and inject the default repository.

Vendor/ModuleName/Configuration/Di.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Configuration;

use Amarant\Framework\Contract\Database\Repository\RepositoryPoolInterface;
use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Contract\Di\DiConfiguratorInterface;
use Amarant\Framework\Data\Set;
use Amarant\Framework\Enum\DiEnum;
use Override;
use Vendor\ModuleName\Contract\Database\Repository\ExampleRepositoryInterface;
use Vendor\ModuleName\DataModel\Example;
use Vendor\ModuleName\DataModel\Repository\ExampleRepository;

class Di implements DiConfiguratorInterface
{
    #[Override] public function configure(ConfigurableContainerInterface $container, Set $configurators): void
    {
        $container
            ->bind(
                ExampleRepositoryInterface::class,
                fn (ConfigurableContainerInterface $container) => $container
                    ->getBindBuilder()
                    ->type(ExampleRepositoryInterface::class)
                    ->concrete(ExampleRepository::class)
                    ->argument(
                        'repository',
                        // (1)
                        DiEnum::getCallInjection(
                            RepositoryPoolInterface::class,
                            'getRepository',
                            [Example::class]
                        )
                    )
                    ->build()
            );
    }
}
  1. Inject a default repository for the Example data model, using call injection on the repository pool.

You can now inject and use ExampleRepositoryInterface.

Migrations


Migrations are used to update the database schema.

They can be executed and reverted. Migrations are executed for each application module, in the order they were added in, and each module is migrated in the order of the module dependency chain.

In the case of revert, same logic applies but in reverse order.

Create a migration


Use the Amarant CLI to create a migration. You must specify the module name.

php bin/ama db:migrations:create Vendor_ModuleName --name=example_init

Note

The name parameter is optional. It's used to add a suffix to the migration file name.

We now have a new migration file created.

Creating code/Vendor/ModuleName/Migrations/1729707441_20241023181721_example_init.php

Let's create a table in the up method and drop it in the down method.

code/Vendor/ModuleName/Migrations/1729707441_20241023181721_example_init.php
<?php

// phpcs:ignorefile
/** Amarant Migration 1729707441_20241023181721_example_init */

use Amarant\Framework\Contract\Database\Connection\ConnectionInterface;
use Amarant\Framework\Contract\Database\Schema\MigrationInterface;

return new class () implements MigrationInterface {
    public function up(ConnectionInterface $connection): void
    {
        $sql = <<<SQL
        CREATE TABLE example (
            id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
            field_a VARCHAR(255) NOT NULL,
            field_b VARCHAR(255) NULL
        );

        CREATE INDEX example_field_a_idx ON example USING btree (field_a);
SQL;
        $connection->execute($sql);
    }

    public function down(ConnectionInterface $connection): void
    {
        $connection->execute('DROP TABLE IF EXISTS example;');
    }
};

Execute and revert migrations


To execute pending migrations, use:

php bin/ama db:migrations:migrate -vvv

To revert migrations, use:

php bin/ama db:migrations:rollback --until=1729707441_20241023181721_example_init -vvv

This is a partial revert. It will skip reverting all other migrations after this particular one. To revert all migrations, remove the until parameter.

Note

You can add the --dry-run parameter to skip database queries, allowing you to see which migrations would be executed or reverted and in which order.

Warning

Doing a full revert, without the until and dry-run parameters effectively removes all defined tables from the database and truncates the migrations table.

Hydrators


Hydrators are used to normalize data loaded from the database and populate a data model with it. The default hydrator populates only the fields that are defined in a table a data model is bound to.

Multiple hydrators can be used to add or change the data model's data, for example to load some other data and enrich the model with it.

Create a hydrator


Check the annotations to know more.

Vendor/ModuleName/DataModel/Hydrator/ExampleHydrator.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\DataModel\Hydrator;

use Amarant\Framework\Contract\Database\Connection\ConnectionInterface;
use Amarant\Framework\Contract\Database\DataModel\DataModelInterface;
use Amarant\Framework\Contract\Database\HydratorInterface;
use Amarant\Framework\Data\Database\DataConverter;
use Amarant\Framework\Database\Registry;
use InvalidArgumentException;
use Override;
use Vendor\ModuleName\DataModel\Example;

final class ExampleHydrator implements HydratorInterface
{
    // (1)
    #[Override] public function supports(string|DataModelInterface $className, mixed $source): bool
    {
        if ($className instanceof DataModelInterface) {
            $className = \get_class($className);
        }

        return $className === Example::class && \is_array($source);
    }

    #[Override] public function hydrate(
        DataModelInterface $dataModel,
        ConnectionInterface $connection,
        Registry $registry,
        mixed $source
    ): void {
        // (2)
        if (($source[Example::FIELD_FIELD_A] ?? null) === 'some value') {
            $dataModel->setSomeOtherData('some other value');
        }

        // (3)
        if ($dataModel->getFieldA() === 'some value') {
            $dataModel->setSomeOtherData('some other value');
        }

        // (4)
        $dataModel->clearModified();
    }
}
  1. Return true if this hydrator supports the data model and the source data. Returning false will result in hydrate not being called on this class.
  2. Use the value from source if the tag priority of this hydrator is before the default hydrator.
  3. Use the value from the data model if the tag priority of this hydrator is after the default hydrator.
  4. Always clear the modified flag on the data model, because every change on the model results in the modified flag being set to true.

Tag the hydrator using dependency injection configurator.

Vendor/ModuleName/Configuration/Di.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Configuration;

use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Contract\Di\DiConfiguratorInterface;
use Amarant\Framework\Data\Set;
use Amarant\Framework\Enum\DiEnum;
use Override;
use Vendor\ModuleName\DataModel\Hydrator\ExampleHydrator;

class Di implements DiConfiguratorInterface
{
    #[Override] public function configure(ConfigurableContainerInterface $container, Set $configurators): void
    {
        $container
            ->tag(
                [
                    // (1)
                    450 => ExampleHydrator::class,
                ],
                [DiEnum::DB_HYDRATOR]
            );
    }
}
  1. Use priority lower than 500 to run this hydrator after the default one or use priority greater than 500 to run before it.

Persisters


Persisters are used to save data to the database, converting the values to database suitable format, depending on field types, in the process.

The default persister saves only the fields that are defined in a table a data model is bound to.

As with hydrators, multiple persisters can be used, most commonly to save data to related tables.

Create a persister


Check the annotations to know more.

Vendor/ModuleName/DataModel/Persister/ExamplePersister.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\DataModel\Persister;

use Amarant\Framework\Contract\Database\Connection\ConnectionInterface;
use Amarant\Framework\Contract\Database\DataModel\DataModelInterface;
use Amarant\Framework\Contract\Database\PersisterInterface;
use Override;
use Vendor\ModuleName\DataModel\Example;

final class ExamplePersister implements PersisterInterface
{
    #[Override] public function supports(DataModelInterface $dataModel): bool
    {
        // (1)
        return $dataModel instanceof Example;
    }

    #[Override] public function persist(
        ConnectionInterface $connection,
        DataModelInterface $dataModel
    ): void {
        // (2)
    }
}
  1. Return true if this persister supports the data model. Returning false will result in persist not being called on this class.
  2. Persisters are executed within a database transaction. Primary key value is already present on the data model if this persister is executed after the default one. It can be used to save data to some other, related tables using the connection or a repository that can be injected in the constructor.

Tag the persister using dependency injection configurator.

Vendor/ModuleName/Configuration/Di.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Configuration;

use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Contract\Di\DiConfiguratorInterface;
use Amarant\Framework\Data\Set;
use Amarant\Framework\Enum\DiEnum;
use Override;
use Vendor\ModuleName\DataModel\Persister\ExamplePersister;

class Di implements DiConfiguratorInterface
{
    #[Override] public function configure(ConfigurableContainerInterface $container, Set $configurators): void
    {
        $container
            ->tag(
                [
                    // (1)
                    450 => ExamplePersister::class,
                ],
                [DiEnum::DB_PERSISTER]
            );
    }
}
  1. Use priority lower than 500 to run this persister after the default one or use priority greater than 500 to run before it.