Persistence

Kinetis connects to MySQL, Postgres, and Redis through clients that never block the rest of your application while waiting on a query — a request that’s waiting on the database doesn’t stop a persistent worker from making progress on anything else in the meantime.

Because of that, connecting through PDO, ext-mysqli, or ext-pgsql directly isn’t supported — the way those work, a query call only returns once the database has responded, and that blocks the entire worker process for as long as it takes, not just the one request. Kinetis’s own clients avoid this by design; you don’t need to think about it once you’re using them.

MariaDB works too, everywhere this page says MySQL — amphp/mysql speaks the wire protocol both databases share, run against a real MariaDB 11.4 server on every push as part of this project’s own CI (see Appendix: Continuous Integration). The one place a specific minimum version matters is kinetis/queue’s SQL backend; see Queue.

Connecting

Register a pool once, in your own bootstrap (public/index.php), before AppScope::boot():

use Amp\Mysql\MysqlConfig;
use Amp\Mysql\MysqlConnectionPool;

$app = new AppScope();

$app->instance(MysqlConnectionPool::class, new MysqlConnectionPool(
    MysqlConfig::fromString(
        "host={$config->string('DB_HOST', '127.0.0.1')} " .
        "dbname={$config->string('DB_NAME', 'app')} " .
        "user={$config->string('DB_USER', 'app')} " .
        "password={$config->required('DB_PASSWORD')}",
    ),
));

$app->boot();

$config is typed Kinetis\Config\Config — see Configuration for the full typed-accessor API. Postgres is the identical pattern with PostgresConnectionPool/PostgresConfig.

A controller or service then gets the pool by constructor injection, like anything else registered on AppScope:

final readonly class OrderController
{
    public function __construct(
        private MysqlConnectionPool $db,
    ) {}

    #[Get('/orders')]
    public function index(): array
    {
        return $this->db->query('SELECT * FROM orders WHERE customer_id = ?', [$customerId]);
    }
}

RequestScope delegates to AppScope only for explicitly registered ids (see Container) — since MysqlConnectionPool::class was registered via instance() above, every request resolves back to that same shared pool, not a fresh one per request.

MysqlConnectionPool/PostgresConnectionPool are themselves full connection pools — Amp\Sql\Common\SqlCommonConnectionPool already handles idle-connection eviction and dead-socket recycling internally. Kinetis’s own Kinetis\Persistence\Pool is not used by this integration — wrapping an already-pooled client in another pool would be pooling a pool. Pool stays available as generic infrastructure for a protocol client that doesn’t already pool itself.

Note

amphp/postgres is not pure-PHP the way amphp/mysql is — it wraps a real Postgres client library and needs ext-pgsql or pecl-pq installed to connect at all. amphp/mysql needs no extension. Neither is a hard Composer requirement; ext-pgsql is listed under Kinetis’s suggest instead, so installing Kinetis doesn’t force a Postgres-specific extension onto a MySQL-only or Redis-only deployment.

Query Builder builds on this same registered pool — pass it to new Query($db) instead of calling ->query() directly.

Multiple databases: named connections

Kinetis\Persistence\SqlConnectionFactory builds a MysqlConnectionPool/PostgresConnectionPool straight from Config — the same connection-string assembly the example above writes by hand, available as a one-line call, and aware of Configuration’s named-connection convention:

use Kinetis\Persistence\SqlConnectionFactory;

$default = SqlConnectionFactory::fromConfig($config);          // DB_*
$reporting = SqlConnectionFactory::fromConfig($config, 'db2'); // DB_DB2_*
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PASSWORD=secret

DB_DB2_CONNECTION=pgsql
DB_DB2_HOST=reporting.internal
DB_DB2_PASSWORD=secret

Register each pool under its own id if you want both reachable through the container:

$app->instance(MysqlConnectionPool::class, SqlConnectionFactory::fromConfig($config));
$app->instance('db.reporting', SqlConnectionFactory::fromConfig($config, 'db2'));

Only the first is autowireable by constructor type-hinting — a named, non-default connection is always retrieved explicitly ($app->get('db.reporting')), never injected by type.

TransactionGuard — the piece AMPHP genuinely can’t provide

Connection pooling is a solved problem once you’re using AMPHP’s clients directly. What they have no way to know about is Kinetis’s RequestScope (see Container): if application code begins a transaction and something throws before it’s explicitly committed or rolled back, nothing closes it — and it leaks into whatever the next thing to borrow that pooled connection does.

Kinetis\Persistence\TransactionGuard is the request-scoped safety net for exactly this. It’s autowired fresh per request, like any other class you haven’t explicitly registered on AppScope, and tracks every transaction it starts.

The safety net for everything else

public function rollbackDangling(): void

For the case the pattern above doesn’t cover — a transaction begun directly via beginTransaction() and held open across multiple calls, that never reaches either commit() or rollback() before the request ends — Kernel registers rollbackDangling() as a RequestScope dispose hook, unconditionally, on every request:

$scope->onDispose($scope->get(Kinetis\Persistence\TransactionGuard::class)->rollbackDangling(...));

This is a genuine no-op for the overwhelming majority of requests that never open a transaction at all — it costs nothing to wire in universally, which is exactly why it’s unconditional rather than opt-in the way, say, MCP support is (see Model Context Protocol (MCP)). When it does find one to close, it logs a warning through whatever logger you’ve registered (see Logging) — a genuine anomaly signal, since it means a transaction was left open somewhere it shouldn’t have been.

Both beginTransaction() and transaction() work identically for MySQL and Postgres: both drivers implement the same Amp\Sql\SqlLink/ SqlTransaction abstraction, so TransactionGuard never needs to know which one it’s actually talking to.

Redis

use function Amp\Redis\createRedisClient;

$redis = createRedisClient('redis://localhost:6379');

$redis->set('session:abc123', $payload);
$value = $redis->get('session:abc123');

amphp/redis’s client already provides everything needed, including automatic reconnection via ReconnectingRedisLink. Redis has no comparable request-spanning transaction concept the way SQL does, so nothing like TransactionGuard applies here.

Psr\SimpleCache\CacheInterface — a PSR-16 cache

A general-purpose PSR-16 cache — not the raw Redis client above, and not Caching & AOT Compilation’s AOT compilation artifacts, a completely different kind of “cache” despite the shared word. Resolvable anywhere via constructor injection with zero setup, like Config/LoggerInterface:

use Psr\SimpleCache\CacheInterface;

final readonly class RateLimiter
{
    public function __construct(
        private CacheInterface $cache,
    ) {}

    public function tooManyAttempts(string $key, int $max): bool
    {
        return ($this->cache->get($key, 0)) >= $max;
    }
}

Optional — Redis is never touched unless configured. If you set REDIS_URL or REDIS_HOST, this connects to Redis automatically with no further setup. If you set neither, CacheInterface still resolves to something — it just always misses and never stores, so anything built against it degrades gracefully instead of throwing.

REDIS_URL=redis://:password@localhost:6379/0
# — or —
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=0
REDIS_TIMEOUT=5

REDIS_URL, if set, wins outright over the discrete parts. Values are serialized with the same Amp\Serialization\NativeSerializer Amp\Redis\RedisCache itself uses internally, so any serializable PHP value — not just strings — can be stored, per the PSR-16 contract.

Both fromConfig() and buildRedisConfig() take an optional string $connection = 'default', following Configuration’s named-connection convention:

$default = RedisSimpleCache::fromConfig($config);            // REDIS_*
$sessions = RedisSimpleCache::fromConfig($config, 'sessions'); // REDIS_SESSIONS_*

To make CacheInterface use a named connection instead of 'default', register it yourself before boot() — your own registration is always kept, never overwritten:

$app->instance(CacheInterface::class, RedisSimpleCache::fromConfig($config, 'sessions'));
$app->boot();

clear() flushes the entire currently selected Redis database — not just keys this cache wrote. Correct when, as recommended above, REDIS_DATABASE points at a database dedicated to Kinetis’s cache; one shared with unrelated data loses it too.

Connecting over TLS

Add REDIS_TLS=true to any of the connections above — single-node or cluster — to connect over TLS:

REDIS_HOST=cache.example.com
REDIS_PORT=6380
REDIS_TLS=true
REDIS_TLS_CA_FILE=/etc/ssl/certs/redis-ca.crt

REDIS_TLS_CA_FILE points at a CA certificate to verify the server against; omit it to use the system’s default trust store. Set REDIS_TLS_VERIFY_PEER=false to skip verification entirely — useful against a self-signed certificate in development, not recommended in production.

Redis Cluster

Set REDIS_CLUSTER=true and REDIS_CLUSTER_SEEDS (a comma-separated list of host:port addresses) instead of REDIS_HOST/REDIS_URL:

REDIS_CLUSTER=true
REDIS_CLUSTER_SEEDS=10.0.0.1:6379,10.0.0.2:6379,10.0.0.3:6379
REDIS_PASSWORD=

Multiple seeds let Kinetis discover the cluster’s layout even if one particular seed happens to be down. Every key is routed to whichever node actually owns it; REDIS_TLS/REDIS_PASSWORD apply to every node the same way. Redis Cluster only supports database 0, so there’s no REDIS_DATABASE option here.

CacheInterface resolves to the same interface either way — application code never needs to know whether it’s talking to a single node or a cluster.

Note

getMultiple()/deleteMultiple()/clear() each dispatch several Redis commands concurrently internally. Don’t call any of them from inside a task you’re already running through concurrently() yourself — nesting one Fiber-driven event loop run inside another isn’t supported.

See also

  • Concurrencyconcurrently(), and how AMPHP’s Amp\Future-based clients compose with Kinetis\Async’s own Fiber-suspension primitives on the same Revolt loop.

  • Container — how TransactionGuard (and any other class you haven’t explicitly registered) actually gets resolved per request.

  • Logging — registering the logger rollbackDangling() warns through.

  • Configuration$config above, typed environment access in full, and the named-connection convention SqlConnectionFactory/RedisSimpleCache both build on.

  • Caching & AOT Compilation — the other “cache” in this codebase: build-time AOT compilation of routes/validation/OpenAPI, unrelated to CacheInterface above beyond the shared word.

  • Query Builder — a thin, parameterized SQL builder on top of the same MySQL/Postgres clients, composing directly with TransactionGuard. A separate kinetis/query-builder package, not core.