Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ config classes for CodeIgniter 4 framework.
[![Coverage Status](https://coveralls.io/repos/github/codeigniter4/settings/badge.svg?branch=develop)](https://coveralls.io/github/codeigniter4/settings?branch=develop)

![PHP](https://img.shields.io/badge/PHP-%5E8.1-blue)
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.2.3-blue)
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.3-blue)
![License](https://img.shields.io/badge/License-MIT-blue)

## Installation
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"require-dev": {
"codeigniter4/devkit": "^1.3",
"codeigniter4/framework": "^4.2.3"
"codeigniter4/framework": "^4.3.0"
},
"minimum-stability": "dev",
"prefer-stable": true,
Expand Down
51 changes: 44 additions & 7 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ 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 into the minimum number of database calls or file writes. The actual changes happen during the `post_system` event.

### Multiple handlers

Example:
Expand All @@ -44,6 +49,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 +61,36 @@ 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 into a single database transaction at the end of the request. This significantly reduces database queries:

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

// With deferWrites = true: 1 query updating 3 properties at the end of the request
```

---

## FileHandler
Expand All @@ -80,20 +102,35 @@ 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: 3 file writes
$settings->set('Example.prop1', 'value1');
$settings->set('Example.prop2', 'value2');
$settings->set('Example.prop3', 'value3');

// With deferWrites = true: 1 file write at end of request
```

---

## ArrayHandler
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ service('settings')->forget('App.siteName');
### Requirements

![PHP](https://img.shields.io/badge/PHP-%5E8.1-red)
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.2.3-red)
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.3-red)

### Acknowledgements

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

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()`.
While `DatabaseHandler` and `FileHandler` use an in-memory cache to maintain fast reads, write operations happen one at a time,
which 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, this means write operations will not appear in CodeIgniter's Debug Toolbar, since the `post_system` event
executes after the toolbar data is collected.

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.
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,
];
}
35 changes: 35 additions & 0 deletions src/Database/Migrations/2025-10-25-194218_AddUniqueKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace CodeIgniter\Settings\Database\Migrations;

use CodeIgniter\Database\Forge;
use CodeIgniter\Database\Migration;
use CodeIgniter\Settings\Config\Settings;

class AddUniqueKey extends Migration
{
private Settings $config;

public function __construct(?Forge $forge = null)
{
$this->config = config('Settings');
$this->DBGroup = $this->config->database['group'] ?? null;

parent::__construct($forge);
}

public function up()
{
$table = $this->config->database['table'];

$this->forge->addUniqueKey(['class', 'key', 'context'], 'settings_class_key_context_idx');
$this->forge->processIndexes($table);
}

public function down()
{
$table = $this->config->database['table'];

$this->forge->dropKey($table, 'settings_class_key_context_idx', 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