Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/Config/WorkerMode.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ class WorkerMode
'cache',
];

/**
* Persistent Factory Components
*
* List of factory component names that should persist across requests.
* Configuration instances ('config') are stateless and safe to retain,
* avoiding repeated file location, parsing, and instantiation.
*
* Components not in this list (such as 'models' or 'cells') will be reset
* between requests to prevent state leakage.
*
* @var list<string>
*/
public array $persistentFactories = [
'config',
];

/**
* Reset Event Listeners
*
Expand Down
2 changes: 2 additions & 0 deletions docs/changelogs/v4.7.7.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Release Date: Unreleased

## Changes

- **Worker Mode:** Added `Factories::resetForWorkerMode()` and `Factories::resetExcept()` to retain stateless factory components (by default `'config'`) across requests in FrankenPHP Worker Mode, avoiding repeated class location, parsing, and instantiation. Added `$persistentFactories` property to `Config\WorkerMode`.

## Deprecations

## Bugs Fixed
Expand Down
13 changes: 11 additions & 2 deletions docs/installation/upgrade_477.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Please refer to the upgrade instructions corresponding to your installation meth

## Mandatory File Changes

### Worker Mode

If you are using Worker Mode, you should update **public/frankenphp-worker.php** after upgrading to benefit from stateless config retention between requests. The easiest way is to re-run the install command:

```console
php spark worker:install --force
```

## Breaking Changes

## Breaking Enhancements
Expand All @@ -25,10 +33,11 @@ The following files received significant changes (including deprecations or visu

#### Config

- @TODO
- **app/Config/WorkerMode.php**
- `Config\WorkerMode::$persistentFactories` has been added, with a default value set to `['config']`. See [Worker Mode](worker_mode.md#persistent-factories) for details.

### All Changes

This is a list of all files in the **project space** that received changes; many will be simple comments or formatting that have no effect on the runtime:

- @TODO
- **app/Config/WorkerMode.php**
15 changes: 14 additions & 1 deletion docs/installation/worker_mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ The key challenge in Worker Mode is preventing state from leaking between reques
Most services are destroyed and recreated for each request. Only services listed in `$persistentServices` survive between requests.

**Factories Reset**
All Factories (Models, Config instances) are reset between requests, ensuring fresh instances without stale data.
Factories are reset between requests to ensure fresh instances without stale data (such as Models and Cells), while stateless components listed in `$persistentFactories` (by default `config`) survive between requests to avoid repeated file location, parsing, and instantiation.

**Superglobals Isolation**
Request data (`$_GET`, `$_POST`, `$_SERVER`, etc.) is properly isolated per request through the Superglobals service.
Expand Down Expand Up @@ -137,6 +137,7 @@ All worker mode configuration is managed through the **app/Config/WorkerMode.php
| Option | Type | Description |
|----|----|----|
| **$persistentServices** | array | Services that persist across requests and are not reset. Services not in this list are destroyed after each request to prevent state leakage. Default: `['autoloader', 'locator', 'exceptions', 'commands', 'codeigniter', 'superglobals', 'routes', 'cache']` |
| **$persistentFactories** | array | Factory components that persist across requests and are not reset. Default: `['config']` |
| **$resetEventListeners** | array | Event names whose listeners are removed between requests. Use this when you register event listeners inside other event callbacks rather than at the top level of **Config/Events.php**, which would cause them to accumulate across requests. Default: `[]` |
| **$forceGarbageCollection** | bool | Whether to force garbage collection after each request. `true` (default, recommended): Prevents memory leaks. `false`: Relies on PHP's automatic garbage collection. |

Expand All @@ -158,6 +159,18 @@ The `$persistentServices` array controls which services survive between requests
!!! warning "Warning"
Adding services to `$persistentServices` without understanding their state management can cause data leakage between requests. Only persist services that are truly stateless or manage their own request isolation.

### Persistent Factories

!!! success "Available from version 4.7.7"

The `$persistentFactories` array controls which factory components survive between requests.

By default, `'config'` is retained across requests:

- Configuration objects (subclasses of `BaseConfig`) represent immutable application and environment settings.
- Retaining them eliminates repetitive file location searches, class existence checks, environment parsing, and instantiation on every request.
- Factory components not listed (such as `'models'` and `'cells'`) are reset between requests to prevent state leakage.

### Reset Event Listeners

!!! success "Available from version 4.7.1"
Expand Down
4 changes: 2 additions & 2 deletions system/Commands/Worker/Views/frankenphp-worker.php.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ while (frankenphp_handle_request($handler)) {
// Cleanup connections with uncommitted transactions
DatabaseConfig::cleanupForWorkerMode();

// Reset factories
Factories::reset();
// Reset factories except persistent ones
Factories::resetForWorkerMode($workerConfig);

// Reset services except persistent ones
Services::resetForWorkerMode($workerConfig);
Expand Down
50 changes: 50 additions & 0 deletions system/Config/Factories.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use CodeIgniter\Database\ConnectionInterface;
use CodeIgniter\Exceptions\InvalidArgumentException;
use CodeIgniter\Model;
use Config\WorkerMode;

/**
* Factories for creating instances.
Expand Down Expand Up @@ -469,6 +470,55 @@ public static function reset(?string $component = null)
self::$updated = [];
}

/**
* Resets the static arrays for all components except the specified ones.
*
* @param list<string> $components Component names to keep
*/
public static function resetExcept(array $components): void
{
$components = array_map(strtolower(...), $components);

foreach (array_keys(self::$options) as $component) {
if (! in_array($component, $components, true)) {
unset(self::$options[$component]);
}
}

foreach (array_keys(self::$aliases) as $component) {
if (! in_array($component, $components, true)) {
unset(self::$aliases[$component]);
}
}

foreach (array_keys(self::$instances) as $component) {
if (! in_array($component, $components, true)) {
unset(self::$instances[$component]);
}
}

foreach (array_keys(self::$updated) as $component) {
if (! in_array($component, $components, true)) {
unset(self::$updated[$component]);
}
}
}

/**
* Resets factories for Worker Mode.
*
* Preserves only persistent factory components defined in WorkerMode config.
*
* Called at the END of each request to clean up state.
*/
public static function resetForWorkerMode(?WorkerMode $config = null): void
{
$config ??= self::config(WorkerMode::class);
$persistentFactories = $config instanceof WorkerMode ? $config->persistentFactories : ['config'];

self::resetExcept($persistentFactories);
}

/**
* Helper method for injecting mock instances
*
Expand Down
1 change: 1 addition & 0 deletions tests/system/Commands/Worker/WorkerCommandsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ public function testWorkerInstallCreatesValidPHPFile(): void
$this->assertStringContainsString('frankenphp_handle_request', (string) $content);
$this->assertStringContainsString('DatabaseConfig::reconnectForWorkerMode', (string) $content);
$this->assertStringContainsString('DatabaseConfig::cleanupForWorkerMode', (string) $content);
$this->assertStringContainsString('Factories::resetForWorkerMode', (string) $content);
}

public function testWorkerInstallCreatesValidCaddyfile(): void
Expand Down
87 changes: 87 additions & 0 deletions tests/system/Config/FactoriesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use CodeIgniter\Test\CIUnitTestCase;
use Config\App;
use Config\Database;
use Config\WorkerMode;
use PHPUnit\Framework\Attributes\Depends;
use PHPUnit\Framework\Attributes\Group;
use ReflectionClass;
Expand Down Expand Up @@ -526,4 +527,90 @@ public function testGetNonexistentClass(): void

$this->assertNull($config);
}

public function testResetExcept(): void
{
$config = Factories::config('App');
$model = Factories::models(UserModel::class);

$this->assertSame($config, Factories::get('config', 'App'));
$this->assertSame($model, Factories::get('models', UserModel::class));

Factories::resetExcept(['config']);

$this->assertSame($config, Factories::get('config', 'App'));
$this->assertNotSame($model, Factories::models(UserModel::class));
}

public function testResetExceptCaseInsensitive(): void
{
$config = Factories::config('App');
$model = Factories::models(UserModel::class);

Factories::resetExcept(['CONFIG']);

$this->assertSame($config, Factories::get('config', 'App'));
$this->assertNotSame($model, Factories::models(UserModel::class));
}

public function testResetExceptEmptyArrayResetsAll(): void
{
$config = Factories::config('App');
$model = Factories::models(UserModel::class);

Factories::resetExcept([]);

$this->assertNotSame($config, Factories::config('App'));
$this->assertNotSame($model, Factories::models(UserModel::class));
}

public function testResetForWorkerModeWithConfig(): void
{
$workerConfig = new WorkerMode();
$workerConfig->persistentFactories = ['config'];

$config = Factories::config('App');
$model = Factories::models(UserModel::class);

Factories::resetForWorkerMode($workerConfig);

$this->assertSame($config, Factories::get('config', 'App'));
$this->assertNotSame($model, Factories::models(UserModel::class));
}

public function testResetForWorkerModeDefault(): void
{
$config = Factories::config('App');
$model = Factories::models(UserModel::class);

Factories::resetForWorkerMode();

$this->assertSame($config, Factories::get('config', 'App'));
$this->assertNotSame($model, Factories::models(UserModel::class));
}

public function testStateIsolationSequentialCycles(): void
{
$workerConfig = new WorkerMode();

// Cycle 1
$config1 = Factories::config('App');
$model1 = Factories::models(UserModel::class);
$model1->allowCallbacks(false);
$this->assertFalse($this->getPrivateProperty($model1, 'tempAllowCallbacks'));

// End of Cycle 1 cleanup
Factories::resetForWorkerMode($workerConfig);

// Cycle 2
$config2 = Factories::config('App');
$model2 = Factories::models(UserModel::class);

// Config is retained across cycles
$this->assertSame($config1, $config2);

// Model is fresh and not polluted by cycle 1 state
$this->assertNotSame($model1, $model2);
$this->assertTrue($this->getPrivateProperty($model2, 'tempAllowCallbacks'));
}
}
Loading