Storh
File-first records for PHP: JSONC documents, append-only segmented logs, and log-backed queues with UUIDv7 ids.
composer require storh/storhLatest release v0.5.2 MIT PHP 8.2+
$pages = $docs
->query()
->where('kind')->eq('page')
->orderBy('title')
->limit(20)
->get();In practice
The quick start in two parts: a document in a storage root, then a log entry and a queue job beside it.
One root, three engines
StorageRoot::resolve() normalizes a base directory and a namespace into a root, and each engine creates its own directory under it on first use.
Every engine returns StorageRecord objects with id() and data(). The queue appends each claim and completion to its event log.
use Storh\DocStore;
use Storh\Queue;
use Storh\RecordQuery;
use Storh\SegmentedLog;
use Storh\StorageRoot;
$root = StorageRoot::resolve(__DIR__ . '/var/storh', 'demo');
$docs = new DocStore($root, 'pages');
$home = $docs->put([
'kind' => 'page',
'title' => 'Home',
]);
$loaded = $docs->get($home->id());$log = new SegmentedLog($root, 'events');
$log->put([
'type' => 'page.saved',
'pageId' => $home->id(),
]);
foreach ($log->stream(RecordQuery::all()->after($home->id())->limit(50)) as $record) {
// Process records lazily.
}
$queue = new Queue($root, 'jobs');
$jobId = $queue->enqueue(['task' => 'render', 'pageId' => $home->id()]);
$job = $queue->claim();
if (null !== $job) {
$queue->complete($job->id());
}JSONC Document Store
One JSONC file per record, UUIDv7 ids, UUID-tail sharding, atomic replacement, and streaming field filters.
Segmented Log
Append-only segments of length- and checksum-prefixed JSON lines, sparse indexes, cursor and time-range reads, crash recovery, and compaction.
Log Queue
Jobs move through pending, processing, and done states using compact append-only queue events.
UUIDv7 Records
Sortable, time-addressable ids for stable cursors, bounded scans, and shard-friendly storage paths.
Atomic Writes
Temporary-file writes and atomic renames keep records whole. After a crashed writer, the next open sweeps its orphaned temp files.
Torn-Write Recovery
Segment reads validate length and checksum fields; recovery truncates partial trailing lines.
File-First
No database server, no framework adapter, no Composer runtime packages; callers pass a directory.
Reads JSONC
Record files may carry comments and trailing commas. storh writes compact single-line JSON, so comments last until the next write.
Query Builder
Fluent queries such as where('status')->in([...]) on DocStore and SegmentedLog; DocStore uses secondary indexes where they exist.
Secondary Indexes
Equality, unique and range indexes stored as files beside the collection, derived from the records and rebuildable at any time.
Schemas
Optional collection schemas validate every write and declare which fields get indexes.
SQL Mirror
Push collections into SQLite or MySQL for joins, search and reporting while the files stay canonical. Needs ext-pdo or ext-mysqli only when used.
Why Storh
A directory, not a server
You pass a base directory and storh writes there. It needs PHP 8.2 and ext-json, with no Composer runtime packages and no framework adapter.
One record shape, three layouts
Documents, logs and queues all store a UUIDv7 id plus an array of data. Choose the disk layout per workload and keep all three under one root.
Checks you can run
verify() checks indexes and log state against a full scan of the files. repair() quarantines corrupt records and truncates torn log tails, and the CLI prints JSON for cron jobs and health checks.
Benchmarks with their setup
The README publishes medians for 50,000 records per engine on an Apple M1 Pro with PHP 8.5, such as 5.6 µs per point read. Every write in those runs is fsynced, so write rates depend on the filesystem.
Compared with
storh is file-first storage rather than a relational query engine, so these tools do some jobs it leaves out.
| Tool | Good at | Difference with Storh |
|---|---|---|
| SQLite | Good atA self-contained SQL engine in a single file, with serializable ACID transactions that hold through a program crash or power failure. Its docs present it as a replacement for ad hoc data files. | Difference with Storhstorh keeps one JSON file per record and its core needs no PDO driver. It has no multi-record transactions, and its docs send that work to a real database. It can push collections into SQLite for joins and reporting while the files stay canonical. |
| MySQL | Good atA relational database served by a multithreaded SQL server, with client programs and libraries, administrative tools and a wide range of APIs. | Difference with Storhstorh runs inside the PHP process against a directory, so there is no server to operate. For joins, search and reporting it mirrors collections into MySQL, also through an existing mysqli handle, and the files remain the source of truth. |
| SleekDB | Good atA flat-file document database in PHP with no third-party dependencies that stores schema-free data as plain JSON files. Its query API includes joins, text search, group by and having, and filters on nested properties. | Difference with Storhstorh pairs its document store with an append-only segmented log and a log-backed queue under one root, and fsyncs every write before reporting it stored. Joins are not part of storh; it pushes collections into SQLite or MySQL for them. |
Questions and answers
Short answers from the Storh documentation.
Yes. DocStore serializes mutations with a collection-level write lock, and concurrent writes to the same id are last-rename-wins. Queue claims hold the queue lock, so two workers do not claim the same pending job.
Yes. Reads accept comments and trailing commas, but the next put() to that id rewrites the file as compact JSON and drops them. With the TRUST cache mode, files changed outside storh are not detected.
Point reads by id stay fast while the filesystem can handle the file count. Unindexed DocStore field scans slow down past roughly 10k records, so declare indexes for the fields you filter on. Segmented-log scans and range reads are comfortable into roughly 1M records.
Writes are fsynced and published by atomic rename, and reopening a log or queue truncates torn tails to the last committed event. Power-loss durability still depends on the filesystem and mount options honoring those syncs.
No. Push collections into SQLite or MySQL with SqlMirror for joins, search and reporting. Multi-record atomic writes remain a job for a real database as the system of record.
No. Queue is not a general record store, and a job's payload is dropped once the job completes.
Start with the quick start, then use Choosing an engine to match DocStore, SegmentedLog or Queue to your workload.