Skip to content
Merged
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
56 changes: 49 additions & 7 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ Example:
public $handlers = ['database'];
```

### Deferred writes

Handlers like `database` and `file` support deferred writes. When `deferWrites` is enabled, multiple `set()` and `forget()` calls
are batched and persisted efficiently at the end of the request during the `post_system` event. This minimizes the number of
database queries or file I/O operations, improving performance for write-heavy operations.

### Multiple handlers

Example:
Expand All @@ -44,6 +50,8 @@ This configuration will:

Only handlers marked as `writeable => true` will be used when calling `set()`, `forget()`, or `flush()` methods.

---

## DatabaseHandler

This handler stores settings in a database table and is production-ready for high-traffic applications.
Expand All @@ -54,21 +62,38 @@ This handler stores settings in a database table and is production-ready for hig
* `table` - The database table name for storing settings. Default: `'settings'`
* `group` - The database connection group to use. Default: `null` (uses default connection)
* `writeable` - Whether this handler supports write operations. Default: `true`
* `deferWrites` - Whether to defer writes until the end of request (`post_system` event). Default: `false`

Example:

```php
public $database = [
'class' => DatabaseHandler::class,
'table' => 'settings',
'group' => null,
'writeable' => true,
'class' => DatabaseHandler::class,
'table' => 'settings',
'group' => null,
'writeable' => true,
'deferWrites' => false,
];
```

!!! note
You need to run migrations to create the settings table: `php spark migrate -n CodeIgniter\\Settings`

**Deferred Writes**

When `deferWrites` is enabled, multiple `set()` or `forget()` calls are batched and persisted in a single transaction at the end of the request. This significantly reduces database queries:

```php
// With deferWrites = false: 1 SELECT (hydrates all settings for context) + 3 separate INSERT/UPDATE queries
$settings->set('Example.prop1', 'value1');
$settings->set('Example.prop2', 'value2');
$settings->set('Example.prop3', 'value3');

// With deferWrites = true: 1 SELECT + 1 INSERT batch + 1 UPDATE batch (all batched at end of request)
```

The deferred approach is especially beneficial when updating existing records or performing many operations in a single request.

---

## FileHandler
Expand All @@ -80,20 +105,37 @@ This handler stores settings as PHP files and is optimized for production use wi
* `class` - The handler class. Default: `FileHandler::class`
* `path` - The directory path where settings files are stored. Default: `WRITEPATH . 'settings'`
* `writeable` - Whether this handler supports write operations. Default: `true`
* `deferWrites` - Whether to defer writes until the end of request (`post_system` event). Default: `false`

Example:

```php
public $file = [
'class' => FileHandler::class,
'path' => WRITEPATH . 'settings',
'writeable' => true,
'class' => FileHandler::class,
'path' => WRITEPATH . 'settings',
'writeable' => true,
'deferWrites' => false,
];
```

!!! note
The `FileHandler` automatically creates the directory if it doesn't exist and checks write permissions on instantiation.

**Deferred Writes**

When `deferWrites` is enabled, multiple `set()` or `forget()` calls to the same class are batched into a single file write at the end of the request. This significantly reduces I/O operations:

```php
// With deferWrites = false: 1 file read (hydrates all settings for class) + 3 separate file writes
$settings->set('Example.prop1', 'value1');
$settings->set('Example.prop2', 'value2');
$settings->set('Example.prop3', 'value3');

// With deferWrites = true: 1 file read + 1 file write (all changes batched at end of request)
```

The deferred approach is especially beneficial when updating multiple properties in the same class.

---

## ArrayHandler
Expand Down
21 changes: 16 additions & 5 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,19 @@

The following are known limitations of the library:

1. You can currently only store a single setting at a time. While the `DatabaseHandler` and `FileHandler`
uses a local cache to keep performance as high as possible for reads, writes must be done one at a time.
2. You can only access the first level within a property directly. In most config classes this is a non-issue,
since the properties are simple values. Some config files, like the `database` file, contain properties that
are arrays.
1. **Immediate writes (`deferWrites => false`)**: Each setting is written to storage immediately when you call `set()` or `forget()`.
The first operation hydrates all settings for that context (1 SELECT query), then each subsequent write performs a separate
INSERT or UPDATE. While `DatabaseHandler` and `FileHandler` use an in-memory cache to maintain fast reads, individual write
operations may result in multiple database queries or file writes per request.

2. **Deferred writes (`deferWrites => true`)**: All settings are batched and written to storage at the end of the request
(during the `post_system` event). This minimizes the number of database queries and file writes, improving performance.
However, there are important considerations:
- Write operations will not appear in CodeIgniter's Debug Toolbar, since the `post_system` event executes after toolbar data collection.
- If the request terminates early (fatal error, `exit()`, etc.) before `post_system`, pending writes are lost.
- Write failures are logged but handled silently - no exceptions are thrown back to the calling code.

3. **First-level property access only**: You can only access the first level of a config property directly. In most config classes
this is not an issue, since properties are simple values. However, some config files (like `Database`) contain properties that
are nested arrays. For example, you cannot directly access `$config->database['default']['hostname']` - you would need to
get the entire `database` property and then access the nested value.
3 changes: 3 additions & 0 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector;
use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPromotedPropertyRector;
use Rector\DeadCode\Rector\MethodCall\RemoveNullArgOnNullDefaultParamRector;
use Rector\EarlyReturn\Rector\Foreach_\ChangeNestedForeachIfsToEarlyContinueRector;
use Rector\EarlyReturn\Rector\If_\ChangeIfElseValueAssignToEarlyReturnRector;
use Rector\EarlyReturn\Rector\If_\RemoveAlwaysElseRector;
Expand Down Expand Up @@ -80,6 +81,8 @@

// May load view files directly when detecting classes
StringClassNameToClassConstantRector::class,

RemoveNullArgOnNullDefaultParamRector::class,
]);
$rectorConfig->rule(SimplifyUselessVariableRector::class);
$rectorConfig->rule(RemoveAlwaysElseRector::class);
Expand Down
16 changes: 9 additions & 7 deletions src/Config/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,20 @@ class Settings extends BaseConfig
* Database handler settings.
*/
public $database = [
'class' => DatabaseHandler::class,
'table' => 'settings',
'group' => null,
'writeable' => true,
'class' => DatabaseHandler::class,
'table' => 'settings',
'group' => null,
'writeable' => true,
'deferWrites' => false,
];

/**
* File handler settings.
*/
public $file = [
'class' => FileHandler::class,
'path' => WRITEPATH . 'settings',
'writeable' => true,
'class' => FileHandler::class,
'path' => WRITEPATH . 'settings',
'writeable' => true,
'deferWrites' => false,
];
}
79 changes: 71 additions & 8 deletions src/Handlers/ArrayHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace CodeIgniter\Settings\Handlers;

use CodeIgniter\Events\Events;

/**
* Array Settings Handler
*
Expand All @@ -15,18 +17,33 @@ class ArrayHandler extends BaseHandler
* Storage for general settings.
* Format: ['class' => ['property' => ['value', 'type']]]
*
* @var array<string,array<string,array>>
* @var array<string, array<string, array{mixed, string}>>
*/
private array $general = [];

/**
* Storage for context settings.
* Format: ['context' => ['class' => ['property' => ['value', 'type']]]]
*
* @var array<string,array|null>
* @var array<string, array<string, array<string, array{mixed, string}>>>
*/
private array $contexts = [];

/**
* Whether to defer writes until the end of request.
* Used by handlers that support deferred writes.
*/
protected bool $deferWrites = false;

/**
* Array of properties that have been modified but not persisted.
* Used by handlers that support deferred writes.
* Format: ['key' => ['class' => ..., 'property' => ..., 'value' => ..., 'context' => ..., 'delete' => ...]]
*
* @var array<string, array{class: string, property: string, value: mixed, context: string|null, delete: bool}>
*/
protected array $pendingProperties = [];

public function has(string $class, string $property, ?string $context = null): bool
{
return $this->hasStored($class, $property, $context);
Expand Down Expand Up @@ -117,16 +134,62 @@ protected function forgetStored(string $class, string $property, ?string $contex
}

/**
* Retrieves all stored properties for a specific class and context.
* Marks a property as pending (needs to be persisted).
* Used by handlers that support deferred writes.
*
* @return array<string,array> Format: ['property' => ['value', 'type']]
* @param mixed $value
*/
protected function getAllStored(string $class, ?string $context): array
protected function markPending(string $class, string $property, $value, ?string $context, bool $isDelete = false): void
{
if ($context === null) {
return $this->general[$class] ?? [];
$key = $class . '::' . $property . ($context === null ? '' : '::' . $context);
$this->pendingProperties[$key] = [
'class' => $class,
'property' => $property,
'value' => $value,
'context' => $context,
'delete' => $isDelete,
];
}

/**
* Groups pending properties by class+context combination.
* Useful for handlers that need to persist changes on a per-class basis.
* Format: ['key' => ['class' => ..., 'context' => ..., 'changes' => [...]]]
*
* @return array<string, array{class: string, context: string|null, changes: list<array{class: string, property: string, value: mixed, context: string|null, delete: bool}>}>
*/
protected function getPendingPropertiesGrouped(): array
{
$grouped = [];

foreach ($this->pendingProperties as $info) {
$key = $info['class'] . ($info['context'] === null ? '' : '::' . $info['context']);

if (! isset($grouped[$key])) {
$grouped[$key] = [
'class' => $info['class'],
'context' => $info['context'],
'changes' => [],
];
}

$grouped[$key]['changes'][] = $info;
}

return $this->contexts[$context][$class] ?? [];
return $grouped;
}

/**
* Sets up deferred writes for handlers that support it.
*
* @param bool $enabled Whether deferred writes should be enabled
*/
protected function setupDeferredWrites(bool $enabled): void
{
$this->deferWrites = $enabled;

if ($this->deferWrites) {
Events::on('post_system', [$this, 'persistPendingProperties']);
}
}
}
12 changes: 12 additions & 0 deletions src/Handlers/BaseHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ public function flush()
throw new RuntimeException('Flush method not implemented for current Settings handler.');
}

/**
* All handlers that support deferWrites MUST support this method.
*
* @return void
*
* @throws RuntimeException
*/
public function persistPendingProperties()
{
throw new RuntimeException('PersistPendingProperties method not implemented for current Settings handler.');
}

/**
* Takes care of converting some item types so they can be safely
* stored and re-hydrated into the config files.
Expand Down
Loading
Loading