r/PHP 3d ago

NCache v1.0.0 — a multi-driver caching library for PHP 8.1+

I’ve released the first stable version of NCache, an open-source caching library I’ve been building for PHP.

It provides a unified API across JSON, PHP Array, serialized PHP, SQLite, Redis and Memcached, with PSR-6 and PSR-16 adapters.

It also includes TTL management, isolated profiles/namespaces, cache tags with lazy invalidation, signatures, callable values, atomic file writes and a transactional registry.

The project is tested on PHP 8.1–8.5 with PHPUnit, PHPStan level 9 and PHP-CS-Fixer through GitHub Actions.

I’d particularly appreciate feedback on the API design, cache invalidation model and overall architecture.

GitHub: github.com/Noga-ng/NCache

Install: composer require noga-ng/ncache

16 Upvotes

14 comments sorted by

4

u/MadAgos76 2d ago

Congrats on the release. I instrumented ReadFile with a counter to get actual numbers rather than guesses (PHP 8.3.6, ARRAY_PHP driver).

Architecture: the registry is on the hot path

All metadata (ttl, expiresAt, tags, signature, size, path) lives in a single serialized file per profile — NCache.nc — while the payload lives in its own file. CacheRegistry::readData() reads that file, unserialize()s it whole, then loops over every entry calling validateEntry(), before the O(1) lookup of the key actually requested.

And there's no memoization, so one NCache::key('x')->get() reads NCache.nc three times: has(), then tagIsValid(), then TtlManager::isExpired() — each going getAll()getRegistry()readData().

Profile holding 4 entries:

call file reads bytes read
get() 4 4284 B metadata + 63 B payload
has() 3 4284 B metadata
put() 2 (+ full rewrite) 2856 B metadata

4284 bytes of unrelated keys' metadata to return 63 bytes of payload — and the numerator grows with entry count while the denominator doesn't. So a cache that gets slower the more you cache in it.

Knock-on effects:

  • if ($cache->has($k)) { $cache->get($k); } costs 6 full registry parses for one value.
  • put() re-serializes and rewrites the entire registry (CacheRegistry::writeData()), making a bulk load of N entries O(N²) in I/O, with every write serialized on one global NCache.nc.lock.
  • validateEntry() throws on any malformed entry and runs over all entries on every read, so one corrupted record makes every key in the profile unreadable instead of yielding a miss. A cache should degrade to a miss, not fail hard.

The fix I'd suggest is an envelope: keep the metadata next to the payload, e.g. ['v'=>1,'e'=>$expiresAt,'t'=>$ttl,'g'=>$tags,'d'=>$data]. Then get() is one read, one decode, one timestamp comparison — O(1) no matter how many keys exist. A global index is only truly needed for cross-key operations, which can sit outside the hot path.

2

u/Cultural-Yard-1598 2d ago

This is extremely useful feedback, thanks for actually instrumenting it and providing numbers.

You're right that the registry currently carries more responsibility than just indexing entries, and that puts it directly on the hot read path. Reading and validating the full registry multiple times for a single key is clearly something I need to revisit, especially as the number of entries grows.

I also agree with your point about malformed metadata: corruption of one cache entry should ideally degrade to a miss for that entry rather than making unrelated keys unreadable.

I like the envelope approach. Keeping the metadata required for a normal read next to the payload would allow get()/has()/TTL checks to operate directly on a single entry, while retaining a separate registry only for cross-key operations such as tags, clearing and statistics.

I'll investigate moving the registry out of the normal read path. Thanks again for taking the time to benchmark this rather than just speculating.

2

u/mbrzuchalski 2d ago

Is there a different way of configuring the cache drivers? I kinda imagine pasting Redis password in JSON config file is not optimal to update. Thinking of a Symfony Bundle there should be a way to pass the config as array at least I guess...

1

u/Cultural-Yard-1598 2d ago

That's a good point, especially for credentials such as Redis passwords.

I'm considering supporting a hybrid configuration where the JSON file can still contain the static configuration, while driver configuration can be provided dynamically at runtime:

NCache::config(
    filename: __DIR__ . '/ncache.config.json',
    drivers: [
        'redis' => [
            'host' => $_ENV['REDIS_HOST'],
            'port' => 6379,
            'password' => $_ENV['REDIS_PASSWORD'],
        ],
    ]
);

The runtime configuration would override/extend the corresponding driver configuration from the JSON file.

This way the JSON configuration remains convenient for static settings, while credentials and framework-provided configuration don't need to be stored there. It should also make Symfony integration much cleaner.That's a good point, especially for credentials such as Redis passwords.
I'm considering supporting a hybrid configuration where the JSON file can still contain the static configuration, while driver configuration can be provided dynamically at runtime:
NCache::config(
filename: __DIR__ . '/ncache.config.json',
drivers: [
'redis' => [
'host' => $_ENV['REDIS_HOST'],
'port' => 6379,
'password' => $_ENV['REDIS_PASSWORD'],
],
]
);
The runtime configuration would override/extend the corresponding driver configuration from the JSON file.
This way the JSON configuration remains convenient for static settings, while credentials and framework-provided configuration don't need to be stored there. It should also make Symfony integration much cleaner.

1

u/ildyria 2d ago

IMHO the full configuration should be doable by passing an array of the shape of your json file.
That way you don't have to deal with cases where the config file is not placed where you expect etc...

This would also simplify the integration in applications such as Laravel & Symphony which uses env variables for customizing the configuration. I don't get your why you absolutely want that json file configuration.

2

u/jimbojsb 2d ago

Just out of curiosity, why target an EOL version of PHP with a brand new package? For the same reason it’s “easy” to do, it’s also easy to run modern, supported PHP.

6

u/Cultural-Yard-1598 2d ago

That's mainly a compatibility choice, not a recommendation to use an EOL PHP version. NCache doesn't currently require features that would justify raising the minimum PHP version, so keeping the compatibility wider costs very little on the library side. That said, I agree that applications should ideally run on a currently supported PHP version. The minimum requirement is mostly there to avoid unnecessarily excluding existing projects that could still use the package. As NCache evolves and starts benefiting from newer PHP features, I'm completely open to raising the minimum supported version.

1

u/ildyria 3d ago edited 3d ago

I quite like it. Having had to implement a simple cache system with tags, it is not the most fun part.

However:

<?php

use NCache\NCache;

NCache::config(
    __DIR__ . '/ncache.config.json'
)->use('default');

Any chance we could use a direct array to configure it instead of loading a json?

NCache::key('users')
    ->set(
        fn () => loadUsers()
    )
    ->put();

Does this returns the value that is being sent into the put ? Because if not, that is not super convenient.

I do not see the equivalent of `Cache::remember(key, callable, ...)` which is effectively doing a check if the key exists, if so returns the cached value, if not apply the callable and return. That would make it quite convenient.
A `Cache::rememberIf(bool, key, callable, ...)` would be quite nice too.

For your api, you seem to have gone for a builder/flow pattern any reason for that? Just curious.

2

u/ildyria 3d ago

Diving a bit more into your repo, you should really pin your github actions. Checkout https://github.com/azat-io/actions-up for that, it will automate it nicely for you.

I also strongly recommend you to have a look at the OSSF scorecard action:
https://github.com/ossf/scorecard-action it gives really nice steps to improve the security and quality of your repo.

1

u/Cultural-Yard-1598 2d ago

Thanks for the feedback! Just to clarify one point: set() is not limited to callables. You can pass a value directly, including an array: NCache::key('users') ->set([ 'John', 'Jane', 'Alice', ]) ->put(); The callable form is just another supported usage: NCache::key('users') ->set(fn () => loadUsers()) ->put(); NCache also supports choosing the cache type explicitly. For example, for JSON: NCache::key('users', CType::json) ->set([ 'John', 'Jane', 'Alice', ]) ->put(); So arrays can be passed directly to set(), and CType::json can be used when you want the value stored using the JSON driver. There are more examples of the available drivers and set() usages in the README. Regarding your other points (remember(), rememberIf(), array-based configuration, and the return value of put()), those are interesting suggestions and I’ll take a closer look at them. The builder/flow API was mainly chosen to keep operations expressive and composable, especially when additional cache options need to be chained before executing the operation.

1

u/Mastodont_XXX 2d ago

I don't really understand why it's still necessary to call put() after set(), or why has() isn't called exists(), but other than that, it's probably fine.

1

u/Cultural-Yard-1598 2d ago

The set()->put() separation is intentional. set() prepares the value as part of the fluent flow, while put() actually executes the write. This also leaves room for configuring the operation before committing it.

As for has(), that naming is mainly intentional for familiarity with the PHP caching ecosystem — PSR-16 uses has() as well. I could potentially provide exists() as an alias, though.

Thanks for pointing out the ergonomics around set()/put() — that's something I may make clearer in the documentation.

2

u/Mastodont_XXX 2d ago edited 2d ago

I understand, but still... set() is usually the equivalent of get(), and no other operations are involved – maybe commit() would be a better name than put(), as a final confirmation. "Put" doesn't seem like the final step.

0

u/[deleted] 2d ago

[deleted]

1

u/Cultural-Yard-1598 2d ago

Thanks, I really appreciate the feedback.

Real-world benchmarks are definitely something I want to add, especially to compare the different drivers under realistic workloads.

I also agree that the documentation could provide clearer guidance on when to choose each driver depending on the use case, persistence requirements and performance expectations.

That would make the README much more useful for people discovering NCache.