OpenKV is a Redis-inspired in-memory key-value database written in PHP with OpenSwoole.
The project is intentionally small, but it is built like infrastructure software: a long-lived TCP daemon, event-driven network callbacks, shared-memory storage, deterministic command handling, TTL expiration, and observable runtime metrics.
This is not a Laravel application and it is not a Redis replacement. The goal is systems understanding: TCP servers, long-running PHP runtimes, OpenSwoole worker lifecycle, memory-backed storage, command parsing, and operational visibility.
- PHP 8.3+
- OpenSwoole extension
- Composer
ncfor manual TCP testing
Install dependencies:
composer installCheck that OpenSwoole is loaded:
php -m | grep openswooleStart OpenKV on the default address:
php bin/swoole-kv server:startDefaults:
host: 127.0.0.1
port: 9501Use a custom host or port:
php bin/swoole-kv server:start --host=127.0.0.1 --port=9601Limit active clients and tune socket admission behavior:
php bin/swoole-kv server:start \
--max-connections=10000 \
--worker-num=1 \
--backlog=2048 \
--heartbeat-idle-time=120 \
--heartbeat-check-interval=30When the application-level connection limit is reached, SwooleKV responds with:
-ERR max clients reachedOpenSwoole cannot accept more connections than the process file descriptor limit allows. If the server refuses to start with a file descriptor message, raise the shell limit before starting it:
ulimit -n 20000
php bin/swoole-kv server:start --max-connections=10000In another terminal:
nc 127.0.0.1 9501The server sends a connection banner:
+OK OpenKV connectedCommands are plain text and line-oriented:
PING
SET name Saboor
GET namePING
+PONGWith one argument, PING echoes that argument as a bulk string:
PING hello
$5
helloSET name Saboor
+OK
GET name
$6
SaboorMissing keys return a null bulk string:
GET missing
$-1SET name Saboor
+OK
EXISTS name
:1
DEL name
:1
EXISTS name
:0Set a TTL in seconds:
SET session abc123
+OK
EXPIRE session 5
:1
TTL session
:5After the key expires:
GET session
$-1
TTL session
:-2TTL response meanings:
-2: key does not exist or has expired-1: key exists with no expiration>= 0: remaining lifetime in seconds
Missing keys start at zero:
INCR count
:1
DECR count
:0Existing integer values are mutated in place:
SET count 10
+OK
INCR count
:11
DECR count
:10Non-integer values are rejected:
SET name Saboor
+OK
INCR name
-ERR Value for key 'name' is not an integer.INFO returns a multi-section runtime report:
INFO
$267
# Server
openkv_version:0.1.0
uptime_seconds:10
worker_count:1
# Clients
max_connections:10000
connections_handled:1
active_connections:1
rejected_connections:0
# Stats
commands_processed:4
requests_per_second:0.40
expired_keys:0
# Storage
total_keys:2
# Memory
memory_usage_bytes:4194304STATS returns a compact counter list:
STATS
$140
commands_processed:3
total_keys:2
expired_keys:0
uptime_seconds:10
requests_per_second:0.30
max_connections:10000
connections_handled:1
active_connections:1
rejected_connections:0The benchmark command uses a client-side connection pool. It opens a fixed number of persistent TCP connections, reads the server banner once per connection, and reuses those sockets across the request count.
php bin/swoole-kv benchmark --connections=100 --requests=100000 --command=PINGExample output:
SwooleKV benchmark complete
command: PING
connections: 100
requests: 100000
successful_requests: 100000
failed_requests: 0
elapsed_seconds: 2.3512
requests_per_second: 42531.47Use connection counts to test client pressure, not one connection per request. For example, 100 hot persistent connections sending 1,000,000 commands is a throughput test, while 10,000 open sockets is primarily a connection scalability test.
Run the test suite:
composer testThe TCP integration tests start the real server on an available local port, connect over TCP, send commands, assert responses, and terminate the server process.
Run PHPUnit directly:
vendor/bin/phpunitRun static analysis:
vendor/bin/phpstan analyse src tests --level=5 --no-progressbin/
swoole-kv
src/
Command/
Console/
Metrics/
Server/
Storage/
Timer/
tests/
Integration/Key boundaries:
CommandParserturns raw TCP input into parsed command objects.CommandHandlervalidates command arguments and formats protocol responses.KeyValueStoredefines the storage boundary.SwooleTableStorestores values and expiration metadata inSwoole\Table.ServerEventHandlerowns OpenSwoole server event callbacks.ConnectionRegistrytracks accepted, active, and rejected clients across workers.ExpirationTimercentralizes background TTL cleanup.ServerMetricstracks runtime counters forINFOandSTATS.
OpenKV is built around one central idea: PHP can be used to study infrastructure systems when it is run as a long-lived event-driven process instead of as a short-lived request script.
OpenSwoole provides the runtime shape for that experiment. The server is not a loop written by hand around blocking socket calls. It is an event-driven TCP daemon where OpenSwoole owns the socket lifecycle and calls PHP code when clients connect, send data, or disconnect. That keeps networking concerns explicit while still letting the application code stay small and readable.
The storage engine uses Swoole\Table because the project is about runtime behavior, not just command syntax. Swoole\Table stores data in shared memory, which makes it a better fit for learning about long-running workers and shared state than a normal PHP array. The current store records both the value and expiration timestamp for each key. Reads, existence checks, deletes, and numeric mutations all treat expired keys as missing, so expiration is part of storage semantics rather than a separate afterthought.
Command execution is intentionally synchronous inside each worker callback. That is acceptable here because commands operate on memory and return quickly. The async part of the system is the TCP server and event dispatch model, not coroutine-based command execution. If a future command performs blocking IO, it should either use OpenSwoole coroutine-aware clients or be isolated so it does not block a worker.
The protocol is deliberately RESP-like but simplified. Simple strings, integers, errors, bulk strings, and null bulk strings are enough to make behavior inspectable with nc while leaving room for future RESP compatibility. The command layer validates argument counts and numeric input before touching storage, so malformed client input produces deterministic errors instead of corrupting state.
Metrics are kept as a first-class runtime concern. INFO and STATS expose command counts, live keys, expired keys, uptime, memory usage, accepted connections, rejected connections, configured connection limit, and worker count. These numbers are not meant to claim production-grade performance. They exist so the server can be observed while it runs and so benchmark work has concrete counters to compare against.
The codebase is intentionally phased. Each commit introduces one coherent capability: server bootstrap, parsing, storage, core commands, TTL, numeric mutation, observability, and TCP integration tests. That history matters because the project is educational. The goal is not only to end up with a small key-value server, but to show how such a system grows from explicit boundaries and runtime responsibilities.
- Persistence is not implemented; all data is in memory.
- The protocol is RESP-like, not fully RESP-compatible.
SETcurrently accepts exactlySET key value; options likeEX,PX,NX, andXXare not implemented.DELandEXISTSaccept one key at a time.- Metrics are process-local and simple.
- Authentication, TLS, replication, pub/sub, streams, and snapshots are not implemented.
- Benchmark tooling is intentionally simple and currently reuses a fixed client-side connection pool.
Near-term improvements:
- Usage-focused documentation examples for more command flows
- Stats command:
php bin/swoole-kv stats - Graceful stop command:
php bin/swoole-kv server:stop - More deterministic unit tests for parser, storage, TTL, and metrics
- RESP compatibility experiments
- Persistence snapshot experiments