Database · Configuration

Database connection

A configuration describes one connection endpoint: driver, address, credentials, schema. The pool builds all of its connections from it, so everything that must be identical across connections lives here — and this is also where migrations are enabled.

Package flytachi/winter-ppaOn top of winter-cdoDrivers PostgreSQL · MySQL · SQLite

What a configuration class is and why

The problem. A database connection is needed in three different places in three different forms: by a repository, to know where to go; by the pool, to open connections; and by migrations and health checks, to walk every database in the project. If the parameters live in an array, each of those places has to learn that array’s key from somewhere, and the link between the string 'main' and a real server exists only in someone’s head.

The solution. A connection point is described by a class. The class is already an identifier: it is referenced by type, not by string. The scanner finds it at startup, so no registry of databases is needed. And a class’s properties are typed, so a $port that arrived from .env as a string does not reach the driver unnoticed.

Declaring one

main/Configurations/MainDbConfig.php
<?php

namespace Main\Configurations;

use Flytachi\Winter\Cdo\Config\PgDbConfig;

class MainDbConfig extends PgDbConfig
{
  public function setUp(): void
  {
      $this->host     = env('DB_HOST', 'localhost');
      $this->port     = (int) env('DB_PORT', 5432);
      $this->database = env('DB_NAME', 'app');
      $this->username = env('DB_USER', 'postgres');
      $this->password = env('DB_PASS', '');
  }
}

setUp() runs once per instance created — that is, once per connection in the pool. Reading the environment here is fine; going to the network or to the database is not.

Generator

php call make -C Main writes a stub into main/Configurations/. Once the class is in the project the scanner picks it up — there is nothing to register.


Reference

Base classes

Choosing a base class is choosing a driver. Their property sets differ because different databases accept different things.

PgDbConfig

Property Type Default What it sets
$host string localhost server address
$port int 5432 port
$database string postgres database name
$username string postgres user
$password string '' password
$schema string public default schema
$charset ?string null connection encoding
$sslmode string disable TLS mode: require, verify-full, …
text
pgsql:host=db;port=5432;dbname=app;sslmode=disable;

$sslmode defaults to disable, and that is not about convenience: under Swoole the PostgreSQL socket is put into non-blocking mode, and libpq’s TLS negotiation conflicts with such a socket — on a cold or remote connection the first attempt fails with could not send SSL negotiation packet.

For a database that requires encryption, override the value in setUp() — 'require' or 'verify-full'. An empty string '' removes the key from the connection string entirely, leaving the decision to libpq itself (its default is prefer).

Sending database traffic in the clear beyond the local machine is not worth it: if the server is reachable over the network, require is the minimum.

MySqlDbConfig

Property Type Default What it sets
$host string localhost server address
$port int 3306 port
$database string '' database name
$username string root user
$password string '' password
$charset ?string null encoding; emoji need utf8mb4
text
mysql:host=db;port=3306;dbname=app;charset=utf8mb4;

MySQL has no schemas: there the database is the namespace, so getSchema() returns null.

SqliteDbConfig

Property Type Default What it sets
$path string :memory: file path, or :memory:
text
sqlite:/var/app.sqlite

No server and no credentials — which is exactly why it is handy in tests. :memory: lives as long as the connection does.

DbConfig

The common base: the same properties plus a $driver you set yourself. Use it for a driver that has no dedicated class.

Shared properties

Property Type Default What it sets
$isPersistent bool false PDO persistent connection

`$isPersistent` and the pool are different things, and you do not want both

A PDO persistent connection outlives a request within one PHP process, and it exists for FPM, where the process would otherwise close the socket every time. In a resident worker connections live anyway — that is the pool’s job, and the pool is what validates and recycles them.

Turn both on and you get a connection the pool considers its own and PDO considers its own, where a close by one is invisible to the other.

Traits

PpaPoolTrait

Lets a configuration set its own pool size. Without it the defaults apply.

php
use Flytachi\Winter\Ppa\Pool\{PpaPoolConfigInterface, PpaPoolTrait};

class MainDbConfig extends PgDbConfig implements PpaPoolConfigInterface
{
  use PpaPoolTrait;

  public int   $poolMaxConnections = 10;
  public float $poolWaitTimeout    = 5.0;

  public function setUp(): void { … }
}
Property Default What it sets
$poolMaxConnections 5 ceiling on connections per worker
$poolWaitTimeout 3.0 deadline for a whole connection borrow
$keepaliveTime 120.0 background validation of idle connections; 0 — off
$idleTimeout 600.0 close idle connections; 0 — never
$minimumIdle 0 warm minimum of connections

The trait declares methods only, no properties — so the class declares them itself and nothing breaks when the interface gains one. The defaults already include background housekeeping: idle connections are pinged and released on their own, so the first request back does not pay for the pause. How to pick the numbers is covered in Connection pool.

PpaCallTrait

Flytachi\\Winter\\Ppa\\PpaCallTrait — short access to a pooled connection straight from the configuration class:

php
$cdo = MainDbConfig::instance();   // the current unit of work's connection
$cte = MainDbConfig::cte();        // a CTE repository on the same database
Method What it returns
instance() the pooled CDO — the same connection this database’s repositories use
cte() a CteRepo bound to this configuration

Application code normally goes through a repository; this is the door for infrastructure code — migrations, one-off maintenance queries, CTEs spanning several tables.

Configuration methods

Application code does not usually call these: the pool hands it a connection and the repository assembles the queries. They are for the places where the application talks to the database directly — a health endpoint, a maintenance script, diagnostics.

setUp()

Fills the configuration’s properties. The one method you have to write yourself.

It is called once per instance created — and an instance is created for every connection in the pool, not once per application. Hence the rule: reading the environment here is fine, going to the network or to the database is not, or opening a connection drags somebody else’s request along with it.

Syntax

php
public function setUp(): void

Parameters

None — the method writes into the object’s properties.

Example

php
public function setUp(): void
{
  $this->host     = env('DB_HOST', 'localhost');
  $this->port     = (int) env('DB_PORT', 5432);
  $this->database = env('DB_NAME', 'app');
  $this->username = env('DB_USER', 'postgres');
  $this->password = env('DB_PASS', '');
}

connection()

Returns the connection, opening it on first use.

The laziness matters here: a configuration object can be created, passed around and taken apart by the scanner without a single socket being opened. The connection appears exactly when it is asked for.

Syntax

php
final public function connection(): CDO

Returns

A CDO — the connection object. It is a PDO with insert, update, delete, batch operations and driver detection added; everything PDO can do works here too.

Errors

CDOException — if the connection could not be established.

This is not the same as taking a connection from the pool

connection() holds the configuration’s own connection, bypassing the pool. What application code needs is instance() — it hands out the connection of the current unit of work, the same one the repositories of this database use.

connect(), disconnect(), reconnect()

Control the socket by hand.

connect() opens the connection if it is not open already; calling it again does nothing. disconnect() releases the reference — the actual close happens when PHP’s garbage collector removes the object. reconnect() is the two in sequence.

Syntax

php
final public function connect(int $timeout = 3): void
final public function disconnect(): void
final public function reconnect(): void

Parameters

$timeout on connect() — how many seconds to wait for the connection to be established. 3 by default.

Errors

CDOException — from connect() and reconnect(), if the connection attempt failed.

ping()

Checks whether the database answers.

Runs a SELECT 1 and reports the result as a boolean. It never throws, under any circumstances: an unreachable server, a wrong password, a broken socket — all of it is false. That is why the method can be called from a health endpoint without a try around it.

Syntax

php
final public function ping(): bool

Returns

true if the database answered; false on any error.

pingDetail()

The same thing, but with the latency and the error text.

For places where “alive or not” is not enough: in a health endpoint’s response the latency shows that the database answers but slowly, and the error text saves a trip to the logs. Like ping(), it does not throw.

Syntax

php
final public function pingDetail(): array

Returns

An array of three keys: status (bool), latency (the response time in milliseconds, rounded to two places) and error (the exception’s message, or null).

Example

php
$health = new MainDbConfig()->pingDetail();

Result

json
{ "status": true, "latency": 1.24, "error": null }

getDns()

Assembles the connection string from the properties.

There is no password in it — credentials are passed to the driver separately — so the string can be printed to a log or to diagnostic output without worry. Each base class adds its own part: PostgreSQL appends sslmode and the encoding, MySQL appends charset.

Syntax

php
public function getDns(): string

Returns

A string of the form:

text
pgsql:host=db;port=5432;dbname=app;sslmode=disable;

The remaining methods

Plain property readers — there is nothing to describe in them.

Method What it returns
getDriver() 'pgsql', 'mysql', 'sqlite'
getSchema() the schema, or null if the driver has none
getUsername() the user name
getPassword() the password
getPersistentStatus() whether PDO persistent connections are on

Environment variables

A configuration reads .env through env(), with the fallback as the second argument:

.env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=app
DB_USER=postgres
DB_PASS=secret

The names are yours; the layer does not dictate them. Keep the fallbacks safe for local development: the application should boot on an empty .env rather than die with “no such variable”.

Binding a repository

A repository points at a configuration by class, not by string:

php
class UserRepository extends Repository
{
  public static string $table         = 'users';
  protected string $dbConfigClassName = MainDbConfig::class;
}

That is what decides which database the query goes to. A rename is handled by the IDE, and a typo becomes a parse-time error instead of a runtime one.

Schema

The schema comes from the configuration ($schema on PostgreSQL) and, when needed, from the repository:

php
class AuditRepository extends Repository
{
  public static string $table = 'events';
  protected ?string $schema   = 'audit';      // → audit.events
}

A repository’s schema overrides the configuration’s. This is what you want when one database holds several logical spaces: public for the application, audit for journals.

Several databases

One class per connection endpoint:

php
class MainDbConfig extends PgDbConfig    { /* the main database */ }
class AuditDbConfig extends PgDbConfig   { /* journals, another server */ }
class LegacyDbConfig extends MySqlDbConfig { /* the old system */ }

Each gets its own pool with its own ceiling, and call db pool lists them separately. Repositories are routed between databases by $dbConfigClassName.

A query does not span databases

Joins, UNION and CTEs run on one connection. Repositories of different configurations cannot be combined in a single query — the data has to be joined in the application, or by the database itself (foreign tables, replication).

Checking connectivity

bash
php call db ping

The command finds every configuration in the project, connects to each and prints the latency. /actuator/health reports the same numbers — there it is pingDetail() doing the work:

json
{ "status": true, "latency": 1.24, "error": null }

ping() never throws: an unreachable database is false, not an exception. That is why it can be called from a health endpoint without a try.

Opting into migrations

A configuration takes part in migrations only when it says so:

php
use Flytachi\Winter\Ppa\Mapping\Attributes\Config\{Extension, Migratable};
use Flytachi\Winter\Ppa\Mapping\Constants\MigratablePriority;

#[Migratable(priority: MigratablePriority::High)]
#[Extension('uuid-ossp')]
class MainDbConfig extends PgDbConfig { … }
Attribute What it does
#[Migratable] lets call db migrate touch this database; priority sets the order
#[Extension] a PostgreSQL extension, created before the tables

Without #[Migratable] the command says plainly what is missing:

text
No migratable configs — add #[Migratable] to a DbConfig to opt in.

The opt-in is not a formality: it keeps the schema tool away from a database somebody else owns — a legacy system the application only reads from, for instance.

Next