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 recommended pattern¶
use Kinetis\Http\Attributes\Body;
use Kinetis\Http\Attributes\Post;
use Kinetis\Persistence\TransactionGuard;
use Amp\Mysql\MysqlConnectionPool;
final readonly class OrderController
{
public function __construct(
private TransactionGuard $transactions,
private MysqlConnectionPool $db,
) {}
#[Post('/orders')]
public function store(#[Body] CreateOrderRequest $data): array
{
return $this->transactions->transaction($this->db, function ($db) use ($data) {
$db->execute('INSERT INTO orders (...) VALUES (...)', [/* ... */]);
$db->execute('UPDATE inventory SET stock = stock - 1 WHERE sku = ?', [$data->sku]);
return ['status' => 'created'];
});
}
}
transaction() commits on success, rolls back on any throw, and always
closes before returning — there’s nothing left for the safety net below to
ever find here. This is the pattern you should reach for by default.
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¶
Concurrency —
concurrently(), and how AMPHP’sAmp\Future-based clients compose withKinetis\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 —
$configabove, typed environment access in full, and the named-connection conventionSqlConnectionFactory/RedisSimpleCacheboth build on.Caching & AOT Compilation — the other “cache” in this codebase: build-time AOT compilation of routes/validation/OpenAPI, unrelated to
CacheInterfaceabove beyond the shared word.Query Builder — a thin, parameterized SQL builder on top of the same MySQL/Postgres clients, composing directly with
TransactionGuard. A separatekinetis/query-builderpackage, not core.