Skip to content
amuraviov.com
Go back

PHP Reflection for Runtime Config: A Legacy Pattern, Its Limits, and How We Evolved It

How a clever but fragile config system led us to rethink everything when long-running workers entered the picture.


Finding the Pattern in the Wild

When you inherit a legacy PHP project with no documentation, you start reading code the way an archaeologist reads soil layers - carefully, looking for intent behind the choices. One of the first things I noticed in this codebase was the config system. It wasn’t using .env files, it wasn’t pulling values directly from a database on each request, and it wasn’t a simple key-value store. It was something more interesting: a layered system that used PHP’s Reflection API to dynamically overwrite static class properties at runtime.

At first glance the config classes looked completely ordinary:

// config/Dynamic/HLRModuleConfig.php
namespace RBT\Config\Dynamic;

class HLRModuleConfig
{
    public static array $successResponseCodes = [
        'SUCCESS0001',
        'SUCCESS0002'
    ];

    public static array $resendRequestResponseCodes = [
        '-3002'
    ];

    public static int $requestTimeout = 30;
}

The defaults live in the file. Nothing unusual. But then at the entry point of every script - schedulers, handlers, API bootstraps - there was always this line:

ConfigStorageCommonModule::applyConfig();

That single call is where things get interesting.


How It Actually Works

The full config pipeline has three layers: database, Redis, and PHP memory.

Database (RBT_config) is the source of truth for editing. An admin UI writes values here. The table mirrors the structure of the Dynamic config classes - one row per module/property pair.

Redis is the working store. It holds the same data as hashes, one hash per config class. Redis is fast, the database is slow, so Redis absorbs all runtime reads.

PHP static properties are what the application actually reads. Every call to HLRModuleConfig::$requestTimeout hits a plain PHP class property in memory - no I/O at all.

The glue between these layers is applyConfig(), which uses ReflectionClass to overwrite the in-memory defaults with values from Redis:

public function applyConfig(): bool
{
    foreach (RedisStorageConfig::$configModules as $module)
    {
        $reflectionClass = new ReflectionClass(
            RedisStorageConfig::$configNamespace . $module
        );

        $moduleConfig = $this->redis->hGetAll($module);

        foreach ($moduleConfig as $property => $value)
        {
            $decodedValue = json_decode($value, true);

            if (json_last_error()) {
                $decodedValue = $value;
            }

            if ($reflectionClass->hasProperty($property)) {
                $reflectionClass->getProperty($property)->setValue($decodedValue);
            }
        }
    }

    return true;
}

After this runs, HLRModuleConfig::$requestTimeout no longer holds 30 - it holds whatever was last written to Redis. The application code never changes; the values underneath it do.

The backup loop runs as a scheduler. When a config value is changed via the admin UI, it sets a REFRESH_DB_CONFIG flag in Redis. A cron job reads that flag, archives the previous DB state, and writes the current Redis values back into the database as a snapshot. Redis is ephemeral; the database is the safety net.

Admin UI → DB (source of truth)
              ↓  initConfig() / restore
           Redis  ←──────────────────── DB backup
              ↓  applyConfig() + Reflection
        PHP static properties (what code reads)

It’s a clever design. For short-lived scripts - HTTP handlers, CLI schedulers - it works well. Each script bootstraps in milliseconds, calls applyConfig() once, and then reads config properties at native PHP speed for its entire lifetime.


The Problem: Long-Lived Workers

The system worked fine until we started introducing Supervisor-managed workers - long-running PHP processes that consume messages from RabbitMQ queues and process billing charges, HLR flag changes, and SMS notifications.

A worker’s lifetime is measured in hours or days, not milliseconds. applyConfig() is called once at startup. If an operator changes HLRModuleConfig::$requestTimeout from 30 to 10 seconds in the admin UI, the Redis value updates immediately - but the workers are still running with the old value baked into their static properties. The only way to propagate the change is to restart every worker process.

For a system processing telecom subscriptions at 50+ TPS, arbitrary restarts are not acceptable.


The Tempting Solution: Go Pure Redis

The obvious fix seemed straightforward: remove the Reflection step entirely and read config values directly from Redis on every access. Replace static property reads with Redis HGET calls wrapped in a base class using __callStatic:

// The idea
class BaseConfig
{
    public static function __callStatic(string $name, array $args): mixed
    {
        return RedisStorageModule::getInstance()
            ->getRedis()
            ->hGet(static::class, $name);
    }
}

// Usage would become:
HLRModuleConfig::requestTimeout(); // Redis HGET on every call

We considered this seriously. Then we counted the problems.

Problem 1: Connection cost. Even with a Redis singleton, every config read becomes a network round-trip. A worker processing 50 messages per second, with each message triggering a dozen config reads, generates 600 Redis operations per second per worker - just for config. With multiple workers, this adds up fast.

Problem 2: Unknown surface area. Not all config classes are registered in $configModules. Some are hardcoded constants, some are environment-specific, some mix static and dynamic properties. A blanket __callStatic approach would silently return null for unregistered properties rather than falling back to the PHP default. Finding every callsite and auditing what’s dynamic versus static would take longer than the problem warranted.

Problem 3: Refactoring volume. The codebase has hundreds of direct property accesses like HLRModuleConfig::$requestTimeout. Converting them all to method calls - HLRModuleConfig::requestTimeout() - across every class, handler, and scheduler, with no automated tests to catch regressions, was an unacceptable risk.

We stepped back. The Reflection approach was not the problem - the problem was that it only ran once.


The Solution: Periodic Re-Application in the Worker Loop

Every Supervisor worker has a while loop at its core. That loop already handles queue polling and TPS throttling. Adding a time-gated applyConfig() call to it costs almost nothing:

public static function Start($processIdentificator, string $scriptName): void
{
    ConfigStorageCommonModule::applyConfig();
    $lastConfigRefresh = time();

    // ... exchange initialization, speed limiter setup ...

    while (AMQPConsumer::getEnabled())
    {
        // Refresh config every 30 seconds (~1–2ms, single Redis connection)
        if (time() - $lastConfigRefresh >= AMQPMessageConfig::$configRefreshTTL) {
            ConfigStorageCommonModule::applyConfig();
            $lastConfigRefresh = time();
        }

        UtilityModule::isServerMaster($scriptName, true);

        AMQPConsumer::startProcess(
            AMQPMessageConfig::QUEUE_CHARGE,
            BillingChargeWorker::class,
            $speedLimiter
        );
    }
}

applyConfig() opens one Redis connection (via a singleton), fetches all config hashes in a loop, and applies them via Reflection. On a local network, this completes in 1–2 milliseconds. At a 30-second interval, the overhead is negligible - less than 0.01% of the worker’s runtime.

The refresh interval itself ($configRefreshTTL) is now a dynamic config value stored in Redis, so operators can tune it without restarting workers. The first time they change it, they wait up to 30 seconds for workers to pick it up; after that, the new interval takes effect.


One More Fix: The TPS Limiter

While reworking the worker loop, we caught a subtle bug in the SpeedLimiter. The limiter was called at the top of the while loop, before attempting to fetch a message from the queue:

while (AMQPConsumer::getEnabled()) {
    $speedLimiter->acquire(); // ← burned a TPS slot regardless
    $msg = $queue->consume();
    if ($msg) { handle($msg); }
}

When the queue is empty, consume() returns null and the slot is wasted. At 50 TPS with an empty queue, the limiter exhausts its entire per-second budget doing nothing - then the actual messages arrive and have to wait until the next second.

The fix was to move acquire() inside the consumer, after a message is successfully fetched and acknowledged but before it is processed:

$msg = $channel->basic_get($queueName);

if ($msg !== null) {
    $channel->basic_ack($msg->getDeliveryTag()); // ack immediately
    $speedLimiter->acquire();                    // throttle before processing
    $msgHandler->handle($msg->getBody());
}

Now TPS slots are only consumed when there is actual work to do. With a shared Redis counter across all worker instances (using a Lua script for atomic increment), the total throughput of the worker pool is correctly bounded.


What We Ended Up With

The final architecture keeps everything that worked about the original design and fixes what didn’t:

The Reflection-based config system was not a hack to be replaced - it was a reasonable solution for its original context. The context changed when workers arrived. The fix was to adapt the bootstrap pattern, not to redesign the data model.

Sometimes the most effective engineering decision is recognizing what you should not rewrite.


Tags: PHP, Redis, Reflection, RabbitMQ, Supervisor, Legacy Code, Configuration Management


Share this post on:

Previous Post
Git Conflicts Explained: A Practical Walkthrough for PHP Developers
Next Post
How to Support Your Team During a Crisis