A lightweight, dependency-free PSR-3 logger with pluggable local and cloud adapters.

Maintainers

Package info

github.com/thingston/psr3

pkg:composer/thingston/psr3

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-07-08 18:21 UTC

This package is auto-updated.

Last update: 2026-07-08 18:36:34 UTC


README

A lightweight, dependency-free PSR-3 logger for PHP 8.4+, with pluggable adapters for local backends (file, syslog, database) and cloud services (CloudWatch, Google Cloud Logging, Azure Monitor, Papertrail, Slack).

  • One hard dependency: psr/log. Everything else (cloud SDKs, curl) is optional and lazily checked.
  • Composable by design: fan a record out to multiple backends with CompositeAdapter, filter by severity with MinLevelAdapter, and swap LineFormatter/JsonFormatter per adapter.
  • 100% test coverage, PHPStan level 8, PSR-12 coding style.

Table of contents

Installation

composer require thingston/psr3

Cloud adapters that wrap a vendor SDK (CloudWatch, Google Cloud Logging) require you to separately install that SDK — see their sections below. Adapters that only need HTTP (Azure, Slack) or raw sockets (Papertrail) work out of the box with ext-curl, which ships with most PHP installs.

Quick start

use Thingston\Log\Logger;
use Thingston\Log\Adapter\StreamAdapter;

$logger = new Logger(new StreamAdapter('php://stderr'), channel: 'app');

$logger->info('User {user} logged in', ['user' => 'ana']);
$logger->error('Payment failed', ['orderId' => 123, 'reason' => 'card_declined']);

Logger implements Psr\Log\LoggerInterface, so it's a drop-in wherever a PSR-3 logger is expected (frameworks, libraries, DI containers).

Core concepts

Class/interface Role
Thingston\Log\Logger PSR-3 logger. Interpolates {placeholders} and hands a LogRecord to one AdapterInterface.
Thingston\Log\LogRecord Immutable value object: level, message (already interpolated), context, channel, timestamp.
Thingston\Log\Level Backed enum of the eight PSR-3 levels, with syslog-style severity ordering (emergency = 0 … debug = 7).
Thingston\Log\Adapter\AdapterInterface The thing you swap: handle(LogRecord $record): void.
Thingston\Log\Formatter\FormatterInterface Turns a LogRecord into a string for adapters that write text (files, syslog, sockets).

Logger only ever talks to a single adapter. To send a record to several backends at once, wrap them in a CompositeAdapter — see Composing adapters. This keeps Logger itself free of routing concerns.

$logger = new Logger($adapter, channel: 'worker'); // channel defaults to "app"

Formatters

Two formatters ship with the package; adapters that produce text default to LineFormatter unless noted.

LineFormatter — plain text, one line per record:

use Thingston\Log\Formatter\LineFormatter;

$formatter = new LineFormatter(
    format: "[{timestamp}] {channel}.{level}: {message} {context}\n", // default
    dateFormat: DateTimeInterface::ATOM,                              // default
    includeContext: true,                                             // default
    appendNewline: true,                                              // default
);

// [2026-07-07T12:00:00+00:00] app.ERROR: Payment failed {"orderId":123,"reason":"card_declined"}

JsonFormatter — single-line structured JSON, for backends that want structured payloads:

use Thingston\Log\Formatter\JsonFormatter;

$formatter = new JsonFormatter(); // {"timestamp":"...","channel":"app","level":"error","message":"...","context":{...}}

Both throw RuntimeException if the record can't be encoded (e.g. non-UTF-8 bytes in the message for JsonFormatter).

Composing adapters

CompositeAdapter

Fans a record out to multiple adapters — e.g. write to a file and alert Slack. A failure in one adapter doesn't stop the others, and doesn't propagate to your application:

use Thingston\Log\Adapter\CompositeAdapter;
use Thingston\Log\Adapter\StreamAdapter;
use Thingston\Log\Adapter\Cloud\SlackWebhookAdapter;
use Thingston\Log\Adapter\MinLevelAdapter;

$composite = new CompositeAdapter([
    new StreamAdapter('/var/log/app.log'),
    new MinLevelAdapter(new SlackWebhookAdapter($webhookUrl), minLevel: 'error'),
], onError: function (\Throwable $e, $adapter, $record): void {
    // optional: report the failure elsewhere (e.g. error_log), without
    // losing the record for the adapters that did succeed.
    error_log(sprintf('Adapter %s failed: %s', $adapter::class, $e->getMessage()));
});

$logger = new Logger($composite);

MinLevelAdapter

Decorator that filters by PSR-3 severity before delegating, so a noisy channel (e.g. Slack) only receives error-and-worse while everything still reaches a file:

use Thingston\Log\Adapter\MinLevelAdapter;
use Psr\Log\LogLevel;

$alertsOnly = new MinLevelAdapter($slackAdapter, LogLevel::ERROR);

Local adapters

All local adapters only need core PHP extensions (ext-pdo, syslog functions), no vendor SDKs.

NullAdapter

Discards everything. Useful as a default/no-op in tests or config-driven setups:

use Thingston\Log\Adapter\NullAdapter;

$logger = new Logger(new NullAdapter());

StreamAdapter

Writes formatted records to a file path (opened in append mode) or an already-open resource:

use Thingston\Log\Adapter\StreamAdapter;

new StreamAdapter('/var/log/app.log');          // file path, opened in "ab" mode
new StreamAdapter('php://stdout');               // any stream wrapper
new StreamAdapter(fopen('php://stderr', 'wb'));   // an existing resource (not closed on destruct)

RotatingFileAdapter

Writes to a date-stamped file, rotating automatically when the date changes, and optionally pruning old files:

use Thingston\Log\Adapter\RotatingFileAdapter;

$adapter = new RotatingFileAdapter(
    filenamePattern: '/var/log/app-{date}.log', // must contain "{date}"
    maxFiles: 14,                                // keep the 14 most recent files; 0 = unlimited
    dateFormat: 'Y-m-d',                          // default
);

SyslogAdapter

Sends records to the system logger via openlog()/syslog(), mapping each PSR-3 level to its syslog priority:

use Thingston\Log\Adapter\SyslogAdapter;

$adapter = new SyslogAdapter(
    ident: 'my-app',
    facility: LOG_USER, // default
    options: LOG_PID,   // default
);

PdoAdapter

Inserts each record as a row via PDO. Ships a createTable() convenience helper for quick starts/tests (SQLite syntax); production schemas should be managed by your own migrations:

use Thingston\Log\Adapter\PdoAdapter;

$pdo = new PDO('mysql:host=localhost;dbname=app', $user, $pass);
$adapter = new PdoAdapter($pdo, table: 'logs'); // table name is validated against ^[A-Za-z_][A-Za-z0-9_]*$

// One-time setup, e.g. in a migration:
// CREATE TABLE logs (
//     id         INTEGER PRIMARY KEY AUTO_INCREMENT,
//     channel    VARCHAR(255) NULL,
//     level      VARCHAR(20)  NOT NULL,
//     message    TEXT         NOT NULL,
//     context    TEXT         NULL,
//     created_at DATETIME     NOT NULL
// );

Cloud adapters

Cloud adapters live under Thingston\Log\Adapter\Cloud. None of them are hard dependencies of the package — install what you use.

CloudWatchLogsAdapter

Requires aws/aws-sdk-php:

composer require aws/aws-sdk-php
use Aws\CloudWatchLogs\CloudWatchLogsClient;
use Thingston\Log\Adapter\Cloud\CloudWatchLogsAdapter;

$client = new CloudWatchLogsClient([
    'region' => 'us-east-1',
    'version' => 'latest',
]);

$adapter = new CloudWatchLogsAdapter($client, logGroupName: '/my/app', logStreamName: gethostname());

The AWS client is invoked duck-typed (only putLogEvents() is called), so the core package never references the SDK's class directly; if aws/aws-sdk-php isn't installed, the constructor throws a clear RuntimeException instead of a confusing autoload failure.

GoogleCloudLoggingAdapter

Requires google/cloud-logging:

composer require google/cloud-logging
use Google\Cloud\Logging\LoggingClient;
use Thingston\Log\Adapter\Cloud\GoogleCloudLoggingAdapter;

$loggingClient = new LoggingClient(['projectId' => 'my-project']);
$logger = $loggingClient->logger('my-app');

$adapter = new GoogleCloudLoggingAdapter($logger);

AzureMonitorAdapter

Talks directly to the Azure Monitor HTTP Data Collector API — no vendor SDK required, just ext-curl (bundled CurlHttpTransport):

use Thingston\Log\Adapter\Cloud\AzureMonitorAdapter;

$adapter = new AzureMonitorAdapter(
    workspaceId: $workspaceId,   // Log Analytics workspace (customer) ID
    sharedKey: $sharedKeyBase64, // primary or secondary workspace key
    logType: 'MyAppLogs',        // custom log type name; default "PsrLog"
);

PapertrailAdapter

Sends RFC 3164 syslog messages over TCP/TLS. Works with Papertrail as well as any other generic syslog-over-TLS endpoint (Loggly, etc.) — just point host/port at theirs:

use Thingston\Log\Adapter\Cloud\PapertrailAdapter;

$adapter = new PapertrailAdapter(
    host: 'logsN.papertrailapp.com',
    port: 12345,
    appName: 'my-app', // default "app"
    useTls: true,       // default true
);

SlackWebhookAdapter

Posts to a Slack Incoming Webhook URL. Intended for alerting — wrap it in MinLevelAdapter so only significant events reach the channel:

use Thingston\Log\Adapter\Cloud\SlackWebhookAdapter;
use Thingston\Log\Adapter\MinLevelAdapter;

$slack = new SlackWebhookAdapter(
    webhookUrl: $webhookUrl,
    channel: '#alerts', // optional, overrides the webhook's default channel
    username: 'app-bot', // optional
);

$adapter = new MinLevelAdapter($slack, minLevel: 'error');

Both AzureMonitorAdapter and SlackWebhookAdapter accept an optional HttpTransportInterface (defaulting to CurlHttpTransport), so you can swap in your own HTTP client (e.g. a PSR-18 bridge) or a test double:

use Thingston\Log\Adapter\Cloud\HttpTransportInterface;

final class MyHttpTransport implements HttpTransportInterface
{
    public function send(string $url, string $body, array $headers = []): void
    {
        // ...
    }
}

$adapter = new SlackWebhookAdapter($webhookUrl, transport: new MyHttpTransport());

Writing your own adapter

Implement the single-method AdapterInterface:

use Thingston\Log\Adapter\AdapterInterface;
use Thingston\Log\LogRecord;

final class MyAdapter implements AdapterInterface
{
    public function handle(LogRecord $record): void
    {
        // $record->level     e.g. "error"
        // $record->message   already PSR-3 interpolated
        // $record->context   the raw context array
        // $record->channel   e.g. "app"
        // $record->timestamp a DateTimeImmutable
    }
}

Throw on failure — wrap with CompositeAdapter if you need one broken backend to not affect others.

PSR-3 message interpolation

Logger replaces {key} placeholders in the message with the corresponding context[key] value, following the PSR-3 reference implementation:

  • Scalars (string, int, float, bool), null, and objects implementing Stringable are replaced.
  • Arrays and non-Stringable objects are left as literal {key} text (they can't be safely cast to string).
  • Placeholders with no matching context key are left untouched.
$logger->warning('User {user} hit rate limit ({count}/{max})', [
    'user' => 'ana',
    'count' => 105,
    'max' => 100,
]);
// "User ana hit rate limit (105/100)"

The raw $context array (not just the interpolated message) is always passed through to LogRecord::$context, so structured adapters (JsonFormatter, PdoAdapter, cloud adapters) can still log it as structured data.

Development

composer install

composer test               # alias for test:unit
composer test:unit          # unit suite: mocks/fakes only, no real extensions or network
composer test:unit:coverage # unit suite with HTML + Clover coverage reports in build/
composer test:integration   # every integration test (skips what isn't configured/available)
composer test:all           # unit + integration
composer phpstan            # static analysis (level 8)
composer cs                 # check coding style (PSR-12)
composer cs:fix             # fix coding style

Tests are split into two suites (tests/Unit, tests/Integration) so mocked behavior and real-resource behavior are never conflated:

Unit suite (tests/Unit)

Runs unconditionally, with no real extensions, network, filesystem daemons, or vendor SDKs required — extension and SDK dependent adapters are exercised against mocks/fakes instead:

  • Logger, formatters, CompositeAdapter, MinLevelAdapter, NullAdapter, StreamAdapter, and RotatingFileAdapter are plain PHPUnit tests (no faking needed).
  • PdoAdapter is tested against createMock(\PDO::class)/createMock(\PDOStatement::class) — no real database driver is touched.
  • SyslogAdapter is tested against openlog()/syslog()/closelog() overrides declared in tests/Fixtures/FunctionOverrides/syslog-functions.php, recorded by tests/Fixtures/FakeSyslog.php.
  • CurlHttpTransport is tested against curl_init()/curl_exec()/etc. overrides in tests/Fixtures/FunctionOverrides/curl-functions.php, recorded by tests/Fixtures/FakeCurl.php.
  • PapertrailAdapter is tested against fsockopen()/stream_socket_client() overrides in tests/Fixtures/FunctionOverrides/socket-functions.php, recorded by tests/Fixtures/FakeSockets.php.
  • AzureMonitorAdapter and SlackWebhookAdapter are tested against an injected HttpTransportInterface double (no curl involved at all).
  • CloudWatchLogsAdapter and GoogleCloudLoggingAdapter are tested via duck-typed client doubles, so the unit suite never needs aws/aws-sdk-php or google/cloud-logging installed.

These PHP-level function overrides work because PHP resolves an unqualified function call (e.g. curl_exec() from a file in Thingston\Log\Adapter\Cloud) against that same namespace first, falling back to the global function — so the fake is only active while a test arms it, and the real extension is used everywhere else, including by the integration suite below.

The unit suite enforces 100% line/method/class coverage (checked in CI). The only lines excluded via @codeCoverageIgnore are assert() type-narrowing calls that PHP itself compiles out when zend.assertions is -1 (the recommended production setting), and are therefore unreachable by design.

Integration suite (tests/Integration)

Exercises real extensions/resources and is grouped per dependency so each can run in isolation:

composer test:integration:curl     # real HTTP request against a local built-in PHP server
composer test:integration:pdo      # real SQLite database via ext-pdo
composer test:integration:syslog   # real openlog()/syslog() calls against the system logger
composer test:integration:sockets  # real TCP socket via ext-sockets

curl, pdo, syslog, and sockets need no external configuration (they spin up a local built-in server, SQLite file, or TCP listener) and run in CI on every PR.

CI runs the unit suite (with the 100% coverage gate, PHPStan, and coding-standards checks) and the full integration suite across PHP 8.4 and 8.5 (see .github/workflows/ci.yml).