A PHP 8.4 Data Access Layer for storing JSON documents and binary attachments with swappable storage backends.
- Installation
- Quick Start
- Adapters
- Entity Definitions
- Working with Records
- Filtering and Searching
- Attachments
- Error Handling
- Switching Adapters
- Typed Records Plugin
- Encryption Plugin
- Integrity Plugin
Install the core library and the adapter(s) you need:
# Core (always required)
composer require kduma/simple-dal
# Pick one or more adapters:
composer require kduma/simple-dal-db-adapter # SQLite
composer require kduma/simple-dal-flysystem-adapter # Flysystem (directory, ZIP, S3, etc.)Requirements: PHP 8.4+
The directory and ZIP adapters depend on league/flysystem ^3.0 (pulled in automatically). The ZIP adapter also pulls in league/flysystem-ziparchive ^3.0.
use KDuma\SimpleDAL\DataStore;
use KDuma\SimpleDAL\Adapter\Database\DatabaseAdapter;
use KDuma\SimpleDAL\Entity\CollectionEntityDefinition;
use KDuma\SimpleDAL\Entity\SingletonEntityDefinition;
use KDuma\SimpleDAL\Query\Filter;
// 1. Create a data store
$store = new DataStore(
adapter: new DatabaseAdapter(new PDO('sqlite:data.sqlite')),
entities: [
new CollectionEntityDefinition('certificates'),
new SingletonEntityDefinition('ca_configuration'),
],
);
// 2. Store data
$cert = $store->collection('certificates')->create([
'subject' => ['commonName' => 'example.com'],
'status' => 'active',
], id: 'cert-01');
// 3. Attach a file
$store->collection('certificates')
->attachments('cert-01')
->put('certificate.pem', $pemContents);
// 4. Search
$active = $store->collection('certificates')->filter(
Filter::where('status', '=', 'active'),
);
// 5. Modify and save
$cert->set('status', 'revoked');
$store->collection('certificates')->save($cert);All adapters implement the same interface. Your business logic never touches the adapter directly -- it works through DataStoreInterface.
Stores records as JSON in SQLite tables, attachments as BLOBs. Single-file deployment.
use KDuma\SimpleDAL\Adapter\Database\DatabaseAdapter;
$adapter = new DatabaseAdapter(new PDO('sqlite:/path/to/data.sqlite'));- One table per entity for records, one for attachments
- Filters translated to SQL
WHEREclauses usingjson_extract() - Expression indexes created for declared
indexedFields - Requires
ext-pdo
Stores each record as a self-contained directory with data.json and attachment files alongside it. Works with any Flysystem filesystem -- local directories, ZIP archives, S3, and more.
Local directory (git-friendly):
use KDuma\SimpleDAL\Adapter\Flysystem\FlysystemAdapter;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
$adapter = new FlysystemAdapter(
new Filesystem(new LocalFilesystemAdapter('/path/to/data')),
);ZIP archive (for export, import, backups):
use KDuma\SimpleDAL\Adapter\Flysystem\FlysystemAdapter;
use League\Flysystem\Filesystem;
use League\Flysystem\ZipArchive\FilesystemZipArchiveProvider;
use League\Flysystem\ZipArchive\ZipArchiveAdapter;
$adapter = new FlysystemAdapter(
new Filesystem(
new ZipArchiveAdapter(new FilesystemZipArchiveProvider('/path/to/archive.zip')),
),
);File layout:
data/
└── certificates/
├── cert-01/
│ ├── data.json # record data (pretty-printed, sorted keys)
│ ├── certificate.pem # attachment
│ └── private_key.pem # attachment
├── cert-02/
│ └── data.json
└── _index.json # sidecar index for indexed fields
- JSON is pretty-printed with recursively sorted keys for clean git diffs
- Sidecar
_index.jsonmaps indexed field values to record IDs for faster equality lookups - Non-indexed filters scan all records in memory
- Requires
league/flysystem ^3.0
Entities are defined when creating the DataStore. There are two types:
Hold zero or more records, each identified by a unique string ID.
use KDuma\SimpleDAL\Entity\CollectionEntityDefinition;
new CollectionEntityDefinition(
name: 'certificates',
hasAttachments: true, // default: true
hasTimestamps: true, // default: true (tracks createdAt/updatedAt)
idField: null, // default: null (auto-extract ID from a data field)
indexedFields: ['status', 'subject.commonName'], // default: []
);idField -- If set, the record ID is extracted from this data field on create() instead of being auto-generated. Supports dot-notation:
new CollectionEntityDefinition(
name: 'certificates',
idField: 'serial_number', // create(['serial_number' => '01', ...]) → ID is '01'
);indexedFields -- Fields to index for faster filtering. Dot-notation supported. The effect depends on the adapter:
- SQLite: creates expression indexes on
json_extract() - Directory/ZIP: maintains a sidecar
_index.jsonfor equality lookups
Hold exactly one record (no ID needed). Useful for configuration or settings.
use KDuma\SimpleDAL\Entity\SingletonEntityDefinition;
new SingletonEntityDefinition(
name: 'ca_configuration',
hasAttachments: true, // default: true
hasTimestamps: true, // default: true
);$certs = $store->collection('certificates'); // → CollectionEntityInterface
$config = $store->singleton('ca_configuration'); // → SingletonEntityInterface
// Introspection
$store->hasEntity('certificates'); // true
$store->entities(); // ['certificates' => ..., 'ca_configuration' => ...]Calling collection() on a singleton (or vice versa) throws EntityNotFoundException with a helpful message.
Records are mutable data containers. Changes are in-memory until you explicitly persist them via the entity store.
$certs = $store->collection('certificates');
// Auto-generated UUID v7 ID
$record = $certs->create([
'subject' => ['commonName' => 'example.com'],
'status' => 'active',
]);
echo $record->id; // e.g. "0195e7a3-..."
// Explicit ID
$record = $certs->create(
data: ['subject' => ['commonName' => 'example.com']],
id: 'cert-01',
);Record IDs must match [a-zA-Z0-9._-]+ (filesystem-safe). Invalid IDs are rejected with an exception.
// Throws RecordNotFoundException if not found
$record = $certs->find('cert-01');
// Returns null if not found
$record = $certs->findOrNull('cert-01');
// Check existence
$certs->has('cert-01'); // bool
// Get all records
$all = $certs->all(); // RecordInterface[]$record->id; // string
$record->data; // array -- the full data
$record->createdAt; // ?DateTimeImmutable
$record->updatedAt; // ?DateTimeImmutable
// Dot-notation field access
$record->get('subject.commonName'); // "example.com"
$record->get('missing.field', 'default'); // "default"
$record->has('subject.commonName'); // true
// JSON export
$record->toJson(); // compact JSON
$record->toJson(JSON_PRETTY_PRINT); // pretty JSONMutate a record in memory, then persist it through the entity store:
$record = $certs->find('cert-01');
// Fluent setters (chainable)
$record->set('status', 'revoked')
->set('revoked_at', '2026-03-15')
->set('subject.commonName', 'new.example.com'); // dot-notation
// Remove a field
$record->unset('temporary_field');
// Deep merge
$record->merge([
'subject' => ['organization' => 'New Org'], // other subject fields preserved
'new_field' => 'value',
]);
// Persist
$certs->save($record);For simple cases where you don't need to read the record first:
// Partial update (deep merge by ID) -- reads, merges, writes
$certs->update('cert-01', ['status' => 'revoked']);
// Full overwrite -- replaces all data
$certs->replace('cert-01', ['completely' => 'new data']);$certs->delete('cert-01'); // also deletes all attachmentsSingletons have a simplified API with no ID parameter:
$config = $store->singleton('ca_configuration');
// Create or full replace
$config->set([
'issuer' => ['commonName' => 'My Root CA'],
'key_algorithm' => 'EC',
]);
// Read
$record = $config->get(); // throws if not set
$record = $config->getOrNull(); // null if not set
$config->exists(); // bool
// Partial update (deep merge)
$config->update(['key_algorithm' => 'RSA', 'key_size' => 4096]);
// Modify and save
$record = $config->get();
$record->set('key_algorithm', 'EC');
$config->save($record);
// Delete
$config->delete();Build queries with a fluent API. Filters work identically across all adapters.
use KDuma\SimpleDAL\Query\Filter;
$results = $certs->filter(
Filter::where('status', '=', 'active')
->andWhere('not_after', '>', '2026-01-01')
->orderBy('not_after', SortDirection::Asc)
->limit(10),
);Methods:
| Method | Description |
|---|---|
Filter::where($field, $op, $value) |
Static entry point -- creates a filter with the first condition |
->andWhere($field, $op, $value) |
Add an AND condition |
->orWhere($field, $op, $value) |
Add an OR condition |
->orderBy($field, $direction) |
Add a sort (can chain multiple) |
->limit($n) |
Limit number of results |
->offset($n) |
Skip first N results |
You can also start with an empty filter for sort/limit-only queries:
$results = $certs->filter(
(new Filter)->orderBy('priority', SortDirection::Desc)->limit(5),
);All operators work across all three adapters.
| Operator | Example | Description |
|---|---|---|
= |
'status', '=', 'active' |
Equals |
!= |
'status', '!=', 'revoked' |
Not equals |
< |
'age', '<', 30 |
Less than |
> |
'age', '>', 30 |
Greater than |
<= |
'age', '<=', 30 |
Less than or equal |
>= |
'age', '>=', 30 |
Greater than or equal |
contains |
'email', 'contains', 'example' |
String contains (case-insensitive) |
starts_with |
'name', 'starts_with', 'Al' |
Starts with (case-insensitive) |
ends_with |
'email', 'ends_with', '.org' |
Ends with (case-insensitive) |
in |
'status', 'in', ['active', 'pending'] |
Value in array |
not_in |
'status', 'not_in', ['revoked'] |
Value not in array |
Operators can be passed as strings (shown above) or as FilterOperator enum values:
use KDuma\SimpleDAL\Contracts\Query\FilterOperator;
Filter::where('status', FilterOperator::Equals, 'active');Dot-notation for nested fields:
Filter::where('subject.commonName', '=', 'example.com');use KDuma\SimpleDAL\Contracts\Query\SortDirection;
// Single sort
Filter::where('status', '=', 'active')
->orderBy('not_after', SortDirection::Asc);
// Multiple sort fields (chained)
(new Filter)
->orderBy('status', SortDirection::Asc)
->orderBy('not_after', SortDirection::Desc);// Page 1: first 10 results
$page1 = $certs->filter(
(new Filter)->orderBy('created_at', SortDirection::Desc)->limit(10),
);
// Page 2: next 10
$page2 = $certs->filter(
(new Filter)->orderBy('created_at', SortDirection::Desc)->limit(10)->offset(10),
);// Count all
$total = $certs->count();
// Count with filter
$activeCount = $certs->count(Filter::where('status', '=', 'active'));Attachments are binary files associated with a record. Each attachment has a name, MIME type, and content.
Scoped to a specific record by ID:
$attachments = $certs->attachments('cert-01');No record ID needed:
$attachments = $store->singleton('ca_keypair')->attachments();// From string content
$attachments->put('certificate.pem', $pemContent, 'application/x-pem-file');
// From stream resource (for large files)
$stream = fopen('/path/to/large-file.bin', 'r');
$attachments->putStream('backup.bin', $stream, 'application/octet-stream');
fclose($stream);The MIME type defaults to application/octet-stream if omitted.
$att = $attachments->get('certificate.pem'); // throws AttachmentNotFoundException
$att = $attachments->getOrNull('missing'); // null
$att->name; // "certificate.pem"
$att->mimeType; // "application/x-pem-file"
$att->size; // byte count (may be null)
// Read into memory
$content = $att->contents();
// Read as stream (for large files)
$stream = $att->stream();
stream_copy_to_stream($stream, $output);$all = $attachments->list(); // AttachmentInterface[]
$exists = $attachments->has('cert.pem'); // bool$attachments->delete('certificate.pem'); // delete one
$attachments->deleteAll(); // delete all for this recordDeleting a record also deletes all its attachments automatically.
All exceptions implement DataStoreExceptionInterface, enabling broad or narrow catches:
use KDuma\SimpleDAL\Contracts\Exception\DataStoreExceptionInterface;
use KDuma\SimpleDAL\Contracts\Exception\RecordNotFoundException;
// Broad catch
try {
$store->collection('certificates')->find('missing');
} catch (DataStoreExceptionInterface $e) {
// Any DAL error
}
// Narrow catch
try {
$store->collection('certificates')->find('missing');
} catch (RecordNotFoundException $e) {
// Specifically missing record
}Exception classes:
| Exception | Extends | When |
|---|---|---|
EntityNotFoundException |
InvalidArgumentException |
Unknown entity name, or wrong type (collection vs singleton) |
RecordNotFoundException |
RuntimeException |
Record ID does not exist |
DuplicateRecordException |
RuntimeException |
Creating a record with an ID that already exists |
AttachmentNotFoundException |
RuntimeException |
Attachment name does not exist |
CorruptedDataException |
RuntimeException |
Stored data cannot be decoded |
InvalidFilterException |
InvalidArgumentException |
Unsupported filter configuration |
ReadOnlyException |
RuntimeException |
Write operation on a read-only store |
The adapter is a constructor concern. Business logic depends only on DataStoreInterface and never touches the adapter:
use KDuma\SimpleDAL\Contracts\DataStoreInterface;
function generateReport(DataStoreInterface $store): string
{
// This code works identically with SQLite, directory, or ZIP
$certs = $store->collection('certificates')->all();
// ...
}To switch storage, change only the adapter instantiation:
// SQLite
$store = new DataStore(
adapter: new DatabaseAdapter(new PDO('sqlite:data.sqlite')),
entities: $entities,
);
// Directory
$store = new DataStore(
adapter: new FlysystemAdapter(new Filesystem(new LocalFilesystemAdapter('/data'))),
entities: $entities,
);
// ZIP
$store = new DataStore(
adapter: new FlysystemAdapter(new Filesystem(new ZipArchiveAdapter(new FilesystemZipArchiveProvider('data.zip')))),
entities: $entities,
);// Export SQLite → ZIP
foreach ($source->collection('certificates')->all() as $record) {
$target->collection('certificates')->create($record->data, id: $record->id);
foreach ($source->collection('certificates')->attachments($record->id)->list() as $att) {
$target->collection('certificates')
->attachments($record->id)
->put($att->name, $att->contents(), $att->mimeType);
}
}The typed records plugin adds strongly-typed PHP record classes to Simple DAL. Instead of accessing data via $record->get('field'), you define a class with typed properties and #[Field] attributes. The plugin handles hydration and dehydration automatically.
# Install the plugin (pulls in simple-dal automatically)
composer require kduma/simple-dal-typedRequirements: PHP 8.4+ (uses property hooks)
Extend TypedRecord and mark properties with the #[Field] attribute:
use KDuma\SimpleDAL\Typed\Contracts\TypedRecord;
use KDuma\SimpleDAL\Typed\Contracts\Attribute\Field;
class CertificateRecord extends TypedRecord
{
#[Field]
public string $serialNumber; // maps to "serial_number" (auto snake_case)
#[Field(path: 'subject.common_name')]
public string $commonName; // maps to nested "subject.common_name"
#[Field]
public CertificateStatus $status; // BackedEnum -- auto converter
#[Field]
public ?string $revocationReason; // nullable
}Path mapping rules:
- No
path:argument -- property name is converted from camelCase to snake_case (e.g.$serialNumber→serial_number) - Explicit
path:-- used as-is, supports dot-notation for nested fields (e.g.subject.common_name)
Converters transform values between PHP types and storage format.
Automatic converters:
BackedEnum-typed properties are automatically converted usingEnumConverter(no configuration needed)
Built-in converters:
use KDuma\SimpleDAL\Typed\Converter\DateTimeConverter;
class CertificateRecord extends TypedRecord
{
#[Field(converter: DateTimeConverter::class)]
public \DateTimeImmutable $notAfter; // stored as ISO 8601 string
}Custom converters implement FieldConverterInterface:
use KDuma\SimpleDAL\Typed\Contracts\Converter\FieldConverterInterface;
class MoneyConverter implements FieldConverterInterface
{
public function fromStorage(mixed $value): mixed
{
return new Money($value);
}
public function toStorage(mixed $value): mixed
{
return $value->cents;
}
}Use TypedCollectionDefinition and TypedSingletonDefinition instead of the untyped variants. They accept the same parameters plus recordClass and attachmentEnum:
use KDuma\SimpleDAL\Typed\Entity\TypedCollectionDefinition;
use KDuma\SimpleDAL\Typed\Entity\TypedSingletonDefinition;
// Collection with typed records and typed attachments
new TypedCollectionDefinition(
name: 'certificates',
recordClass: CertificateRecord::class,
attachmentEnum: CertificateAttachment::class, // optional
indexedFields: ['status', 'subject.common_name'],
);
// Singleton with typed records
new TypedSingletonDefinition(
name: 'ca_config',
recordClass: CaConfigRecord::class,
);TypedDataStore wraps DataStore and returns typed records instead of generic RecordInterface:
use KDuma\SimpleDAL\Adapter\Database\DatabaseAdapter;
use KDuma\SimpleDAL\Typed\TypedDataStore;
$store = new TypedDataStore(
adapter: new DatabaseAdapter(new PDO('sqlite:data.sqlite')),
entities: [
new TypedCollectionDefinition(
name: 'certificates',
recordClass: CertificateRecord::class,
),
new TypedSingletonDefinition(
name: 'ca_config',
recordClass: CaConfigRecord::class,
),
],
);
$certs = $store->collection('certificates'); // → TypedCollectionEntity
$config = $store->singleton('ca_config'); // → TypedSingletonEntityUse make() to create a blank typed record, set its properties using native PHP types, then persist with create() or set():
$certs = $store->collection('certificates');
// Create a blank record and populate it
$cert = $certs->make();
$cert->serialNumber = '01';
$cert->commonName = 'example.com';
$cert->status = CertificateStatus::Active; // enum, not string
$cert->notAfter = new DateTimeImmutable('2027-01-01'); // DateTimeImmutable, not string
$cert->revocationReason = null;
// Persist
$cert = $certs->create($cert, id: 'cert-01');
// Access typed properties
echo $cert->commonName; // "example.com"
echo $cert->status->value; // "active"
echo $cert->notAfter->format('Y'); // "2027"
// Find and modify
$cert = $certs->find('cert-01');
$cert->status = CertificateStatus::Revoked;
$cert->revocationReason = 'key_compromise';
$cert = $certs->save($cert);
// Other operations
$cert = $certs->find('cert-01'); // CertificateRecord
$all = $certs->all(); // CertificateRecord[]Singletons work the same way:
$config = $store->singleton('ca_config');
// Create via make() + set()
$record = $config->make();
$record->issuerName = 'My Root CA';
$record->keyAlgorithm = 'EC';
$record->curve = 'P-384';
$config->set($record);
// Read
$record = $config->get(); // CaConfigRecord
echo $record->issuerName; // "My Root CA"
echo $record->keyAlgorithm; // "EC"
// Modify and save
$record->keyAlgorithm = 'RSA';
$record->curve = null;
$config->save($record);Define a string-backed enum for attachment names, then use it instead of raw strings:
enum CertificateAttachment: string
{
case Certificate = 'certificate.pem';
case PrivateKey = 'private_key.pem';
}
// Pass the enum in TypedCollectionDefinition
new TypedCollectionDefinition(
name: 'certificates',
recordClass: CertificateRecord::class,
attachmentEnum: CertificateAttachment::class,
);
// Use enum values instead of strings
$attachments = $store->collection('certificates')->attachments('cert-01');
$attachments->put(CertificateAttachment::Certificate, $pemContent);
$attachments->has(CertificateAttachment::PrivateKey); // false
$att = $attachments->get(CertificateAttachment::Certificate);
echo $att->contents();
$attachments->delete(CertificateAttachment::Certificate);The encryption plugin adds transparent, selective encryption of attachments using libsodium. Attachments are encrypted on write and decrypted on read, based on configurable rules. Multiple keys, key rotation, and both symmetric and asymmetric encryption are supported.
# Core + libsodium keys
composer require kduma/simple-dal-encryption kduma/simple-dal-encryption-sodium
# Or with phpseclib keys (RSA, AES)
composer require kduma/simple-dal-encryption kduma/simple-dal-encryption-phpseclibRequirements: PHP 8.4+. Sodium keys need ext-sodium. PhpSecLib keys need phpseclib/phpseclib ^3.0.
Symmetric (SymmetricKey) -- uses sodium_crypto_secretbox (XSalsa20-Poly1305). Same key for encrypt and decrypt.
use KDuma\SimpleDAL\Encryption\Sodium\SecretBoxAlgorithm;
$key = new SecretBoxAlgorithm(
id: 'master',
key: sodium_crypto_secretbox_keygen(), // 32 bytes
);Asymmetric (KeyPair) -- uses sodium_crypto_box_seal (X25519+XSalsa20-Poly1305). Encrypt with public key only; decrypt requires the secret key.
use KDuma\SimpleDAL\Encryption\Sodium\SealedBoxAlgorithm;
$keypair = sodium_crypto_box_keypair();
$key = new SealedBoxAlgorithm(
id: 'sealed',
publicKey: sodium_crypto_box_publickey($keypair),
secretKey: sodium_crypto_box_secretkey($keypair), // optional — omit for encrypt-only
);Rules define which attachments to encrypt and with which key. Rules are evaluated in order; the first match wins.
use KDuma\SimpleDAL\Encryption\EncryptionConfig;
use KDuma\SimpleDAL\Encryption\EncryptionRule;
$config = new EncryptionConfig(
keys: [$masterKey, $sealedKey],
rules: [
// Encrypt specific attachment by name
new EncryptionRule(
keyId: 'master',
entityName: 'certificates',
attachmentNames: 'private_key.pem',
),
// Encrypt multiple attachments (accepts BackedEnum values)
new EncryptionRule(
keyId: 'master',
entityName: 'certificates',
attachmentNames: [CertAttachment::PrivateKey, CertAttachment::Certificate],
),
// Encrypt all attachments in an entity
new EncryptionRule(
keyId: 'sealed',
entityName: 'secrets',
),
// Encrypt only for specific record IDs
new EncryptionRule(
keyId: 'master',
entityName: 'users',
attachmentNames: 'avatar.png',
recordIds: ['user-1', 'user-2'],
),
],
);Wrap any adapter with EncryptingStorageAdapter for transparent encryption:
use KDuma\SimpleDAL\Encryption\EncryptingStorageAdapter;
$adapter = new EncryptingStorageAdapter(
inner: new DatabaseAdapter(new PDO('sqlite:data.sqlite')),
config: $config,
);
// Use $adapter as the adapter for DataStore — everything else is unchanged
$store = new DataStore(adapter: $adapter, entities: [...]);
// Attachments matching rules are encrypted/decrypted transparently
$store->collection('certificates')
->attachments('cert-01')
->put('private_key.pem', $pemContent); // encrypted in storage
$content = $store->collection('certificates')
->attachments('cert-01')
->get('private_key.pem')
->contents(); // decrypted on readNon-matching attachments pass through as plaintext. Listing records, reading record data, and other operations are never affected by encryption.
If an attachment is encrypted but the required key is missing from the config, a DecryptionException is thrown on readAttachment().
Use EncryptionMigrator to re-encrypt attachments after changing keys or rules:
use KDuma\SimpleDAL\Encryption\EncryptionMigrator;
$newConfig = new EncryptionConfig(
keys: [
$newKey, // active key
$oldKey, // kept for decrypting existing data
],
rules: [
new EncryptionRule(keyId: 'new-key', entityName: 'certificates'),
],
);
$migrator = new EncryptionMigrator($innerAdapter, $newConfig);
$migrator->migrate(['certificates', 'secrets']);The migrator handles all transitions:
- Unencrypted to encrypted (new rule added)
- Key A to key B (rule changed)
- Encrypted to unencrypted (rule removed)
- Already correct (skipped)
The kduma/simple-dal-encryption-phpseclib package provides key implementations using phpseclib3:
RSA encryption (OAEP or PKCS1 padding):
use KDuma\SimpleDAL\Encryption\PhpSecLib\RsaAlgorithm;
use phpseclib3\Crypt\RSA;
// Pass a PrivateKey for encrypt + decrypt
$privateKey = RSA::createKey(2048)->withPadding(RSA::ENCRYPTION_OAEP)->withHash('sha256');
$key = new RsaAlgorithm(id: 'rsa-key', key: $privateKey);
// Or pass a PublicKey for encrypt-only
$key = new RsaAlgorithm(id: 'rsa-key', key: $privateKey->getPublicKey());Symmetric encryption (AES, ChaCha20, etc.):
use KDuma\SimpleDAL\Encryption\PhpSecLib\AesAlgorithm;
use phpseclib3\Crypt\AES;
$cipher = new AES('ctr');
$cipher->setKey($key32bytes);
$key = new AesAlgorithm(id: 'aes-key', cipher: $cipher);Both key types work interchangeably with sodium keys in EncryptionConfig.
The integrity plugin adds transparent checksum and signature verification to records and attachments. Every write computes a hash (and optionally a cryptographic signature); every read verifies it. If data has been tampered with, an IntegrityException is thrown.
# Core plugin (always required)
composer require kduma/simple-dal-integrity
# Pick a hashing / signing provider:
composer require kduma/simple-dal-integrity-sodium # Blake2b + Ed25519
composer require kduma/simple-dal-integrity-hash # PHP hash() / hash_hmac()
composer require kduma/simple-dal-integrity-phpseclib # RSA, EC, DSA via phpseclib3Requirements: PHP 8.4+. Sodium algorithms need ext-sodium. PhpSecLib algorithms need phpseclib/phpseclib ^3.0.
Hashing algorithms implement HashingAlgorithmInterface and compute a checksum for each record and attachment.
Libsodium -- Blake2b (kduma/simple-dal-integrity-sodium):
use KDuma\SimpleDAL\Integrity\Sodium\Blake2bHashingAlgorithm;
$hasher = new Blake2bHashingAlgorithm();Uses sodium_crypto_generichash() (BLAKE2b, 32-byte output). No configuration needed.
PHP hash() extensions (kduma/simple-dal-integrity-hash):
use KDuma\SimpleDAL\Integrity\Hash\Hasher\Sha256HashingAlgorithm;
use KDuma\SimpleDAL\Integrity\Hash\Hasher\Sha512HashingAlgorithm;
use KDuma\SimpleDAL\Integrity\Hash\Hasher\Sha3_256HashingAlgorithm;
use KDuma\SimpleDAL\Integrity\Hash\Hasher\Sha1HashingAlgorithm;
use KDuma\SimpleDAL\Integrity\Hash\Hasher\Md5HashingAlgorithm;
use KDuma\SimpleDAL\Integrity\Hash\Hasher\Crc32HashingAlgorithm;
$hasher = new Sha256HashingAlgorithm(); // recommended defaultConvenience classes for common algorithms. All use PHP's built-in hash() function. For custom algorithms, use GenericPhpHashingAlgorithm directly:
use KDuma\SimpleDAL\Integrity\Hash\Hasher\GenericPhpHashingAlgorithm;
$hasher = new GenericPhpHashingAlgorithm('sha384', algorithmId: 200);Signing algorithms implement SigningAlgorithmInterface and add a cryptographic signature alongside the hash. Signing is optional -- you can use hashing alone for checksum-only integrity.
Ed25519 (kduma/simple-dal-integrity-sodium):
use KDuma\SimpleDAL\Integrity\Sodium\Ed25519SigningAlgorithm;
// Construct from existing keys
$signer = new Ed25519SigningAlgorithm(
id: 'signing-key-01',
secretKey: $secretKeyBytes, // 64 bytes, null for verify-only
publicKey: $publicKeyBytes, // 32 bytes
);
// Verify-only (no secret key — cannot sign, only verify)
$verifier = Ed25519SigningAlgorithm::verifyOnly(id: 'signing-key-01', publicKey: $publicKeyBytes);HMAC (kduma/simple-dal-integrity-hash):
use KDuma\SimpleDAL\Integrity\Hash\Signer\HmacSha256SigningAlgorithm;
use KDuma\SimpleDAL\Integrity\Hash\Signer\HmacSha512SigningAlgorithm;
use KDuma\SimpleDAL\Integrity\Hash\Signer\HmacSha1SigningAlgorithm;
$signer = new HmacSha256SigningAlgorithm(id: 'hmac-key', secret: $sharedSecret);For custom HMAC algorithms, use GenericHmacSigningAlgorithm:
use KDuma\SimpleDAL\Integrity\Hash\Signer\GenericHmacSigningAlgorithm;
$signer = new GenericHmacSigningAlgorithm(
id: 'custom-hmac',
secret: $sharedSecret,
algo: 'sha384',
algorithmId: 200,
);phpseclib3 (RSA, EC, DSA) (kduma/simple-dal-integrity-phpseclib):
use KDuma\SimpleDAL\Integrity\PhpSecLib\RsaSigningAlgorithm;
use KDuma\SimpleDAL\Integrity\PhpSecLib\EcSigningAlgorithm;
use KDuma\SimpleDAL\Integrity\PhpSecLib\DsaSigningAlgorithm;
use phpseclib3\Crypt\RSA;
use phpseclib3\Crypt\EC;
// RSA signing
$rsaKey = RSA::createKey(2048);
$signer = new RsaSigningAlgorithm(id: 'rsa-sign', key: $rsaKey);
// EC signing (ECDSA)
$ecKey = EC::createKey('secp256r1');
$signer = new EcSigningAlgorithm(id: 'ec-sign', key: $ecKey);
// Verify-only — pass a PublicKey
$verifier = new RsaSigningAlgorithm(id: 'rsa-sign', key: $rsaKey->getPublicKey());IntegrityConfig holds the hashing algorithm, optional signing algorithm, and failure modes:
use KDuma\SimpleDAL\Integrity\IntegrityConfig;
use KDuma\SimpleDAL\Integrity\FailureMode;
$config = new IntegrityConfig(
hasher: $hasher, // optional — null for sign-only
signer: $signer, // optional — null for hash-only
onChecksumFailure: FailureMode::Throw, // default: Throw
onSignatureFailure: FailureMode::Throw, // default: Throw
onMissingIntegrity: FailureMode::Throw, // default: Throw
detachedAttachments: true, // default: true
);detachedAttachments controls how attachment integrity is stored:
true(default): Integrity metadata is stored in a sidecar file (file.txt.sig) alongside the raw attachment. The original attachment remains unmodified — readable by external tools.false: Integrity metadata is embedded inline by wrapping the attachment content in a binary envelope.
Reading auto-detects both modes transparently, so data written in either mode can be read regardless of the current setting.
Wrap any adapter with IntegrityStorageAdapter for transparent integrity protection:
use KDuma\SimpleDAL\Integrity\IntegrityStorageAdapter;
$adapter = new IntegrityStorageAdapter(
inner: new DatabaseAdapter(new PDO('sqlite:data.sqlite')),
config: $config,
);
// Use $adapter as the adapter for DataStore — everything else is unchanged
$store = new DataStore(adapter: $adapter, entities: [...]);
// Records get an _integrity metadata field (stripped on read)
$store->collection('documents')->create(['title' => 'Report'], 'doc-01');
// Attachments are wrapped in an integrity envelope (unwrapped on read)
$store->collection('documents')
->attachments('doc-01')
->put('report.pdf', $pdfContent);
// Reading verifies integrity transparently
$record = $store->collection('documents')->find('doc-01');
$content = $store->collection('documents')
->attachments('doc-01')
->get('report.pdf')
->contents();Records are protected by embedding an _integrity field in the stored JSON (hash, algorithm, and optional signature). This field is automatically stripped when reading. Attachments are protected by wrapping the binary content in an integrity envelope with a magic header.
FailureMode controls what happens when verification fails:
| Mode | Behavior |
|---|---|
FailureMode::Throw |
Throws IntegrityException (default) |
FailureMode::Ignore |
Silently returns the data as-is |
Checksum and signature failures are configured independently:
$config = new IntegrityConfig(
hasher: $hasher,
signer: $signer,
onChecksumFailure: FailureMode::Throw, // hash mismatch → exception
onSignatureFailure: FailureMode::Ignore, // bad signature → return data anyway
onMissingIntegrity: FailureMode::Ignore, // unprotected data → pass through (useful during migration)
);IntegrityException extends CorruptedDataException and exposes entityName, recordId, expectedHash, and actualHash properties for programmatic inspection:
use KDuma\SimpleDAL\Integrity\Exception\IntegrityException;
try {
$record = $store->collection('documents')->find('doc-01');
} catch (IntegrityException $e) {
echo "Tampered: {$e->entityName}/{$e->recordId}";
echo "Expected: ".bin2hex($e->expectedHash);
echo "Got: ".bin2hex($e->actualHash);
}Use IntegrityMigrator to add integrity protection to existing unprotected data, or to re-hash/re-sign after changing algorithms:
use KDuma\SimpleDAL\Integrity\IntegrityMigrator;
$migrator = new IntegrityMigrator($innerAdapter, $config);
$migrator->migrate(['documents', 'certificates']);The migrator processes every record and attachment in the listed entities:
- Unprotected data -- adds integrity metadata (hash + optional signature)
- Already protected -- re-computes with the current config (algorithm change, key rotation)
- Unchanged -- skipped (no unnecessary writes)
The integrity and encryption adapters can be stacked. Apply integrity first (inner), then encryption (outer), so that integrity protects the plaintext and encryption protects everything:
use KDuma\SimpleDAL\Integrity\IntegrityStorageAdapter;
use KDuma\SimpleDAL\Encryption\EncryptingStorageAdapter;
$innerAdapter = new DatabaseAdapter(new PDO('sqlite:data.sqlite'));
// 1. Integrity wraps the raw adapter
$integrityAdapter = new IntegrityStorageAdapter($innerAdapter, $integrityConfig);
// 2. Encryption wraps the integrity adapter
$encryptedAdapter = new EncryptingStorageAdapter($integrityAdapter, $encryptionConfig);
$store = new DataStore(adapter: $encryptedAdapter, entities: [...]);With this ordering, reads flow: storage -> decrypt -> verify integrity -> application. Writes flow: application -> compute integrity -> encrypt -> storage.