This is whats really great about PHP, the time from "I have an idea" to a running implementation can be very quick and minimal. In a world of over-wrought solutions this still stands as a simple way to get shit running in a hurry.
Here is I how I do it with simple SQL and built in PHP constructs, easy to read, debug and implement.
First part of the example is class based and the second one function based if you are more into that.
<?php
declare(strict_types=1);
// SQL with classes
namespace {
$pdo = new PDO('sqlite::memory:');
$pdo->exec(
"CREATE TABLE article (
article_id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)
");
$pdo->exec("INSERT INTO article (name) VALUES('My Article')");
interface Cache
{
public function get(string $key): mixed;
public function set(string $key, mixed $data): void;
}
final class RuntimeCache implements Cache
{
private array $cache = [];
public function get(string $key): mixed
{
return $this->cache[$key] ?? null;
}
public function set(string $key, mixed $data): void
{
$this->cache[$key] = $data;
}
}
}
namespace Article {
use Cache;
use PDO;
readonly class Article
{
public int $article_id;
public string $name;
}
interface ArticleRepository
{
public function getArticleById(int $article_id): Article|null;
}
final class SqlArticleRepository implements ArticleRepository
{
public function __construct(private readonly PDO $pdo)
{
}
public function getArticleById(int $article_id): Article|null
{
$stmt = $this->pdo->prepare(
"SELECT article_id, name
FROM article
WHERE article_id = ?");
$stmt->execute([$article_id]);
$article = $stmt->fetchObject(Article::class);
return $article === false ? null : $article;
}
}
final class CachedArticleRepository implements ArticleRepository
{
public function __construct(private readonly Cache $cache,
private readonly ArticleRepository $articleRepository)
{
}
public function getArticleById(int $article_id): Article|null
{
$key = "article-{$article_id}";
$cached = $this->cache->get($key);
if (!empty($cached)) {
return $cached;
}
$article = $this->articleRepository->getArticleById($article_id);
if ($article !== null) {
$this->cache->set($key, $article);
}
return $article;
}
}
}
namespace {
$repo = new Article\CachedArticleRepository(
new RuntimeCache(),
new Article\SqlArticleRepository($pdo)
);
var_dump($repo->getArticleById(1));
var_dump($repo->getArticleById(1));
}
// SQL with functions
namespace {
function curry(callable $f, ...$args): callable {
$rf = new ReflectionFunction($f);
$count = $rf->getNumberOfParameters();
return function (...$arguments) use ($f, $rf, $count, $args) {
if (count($args) + count($arguments) >= $count) {
return $rf->invokeArgs(array_merge($args, $arguments));
}
return curry($f, ...array_merge($args, $arguments));
};
}
}
namespace Article\SqlRepository
{
use Article\Article;
function getArticleById(\PDO $pdo, int $article_id): Article|null
{
$stmt = $pdo->prepare(
"SELECT article_id, name
FROM article
WHERE article_id = ?");
$stmt->execute([$article_id]);
$article = $stmt->fetchObject(Article::class);
return $article === false ? null : $article;
}
}
namespace Article\CachedArticleRepository
{
use Article\Article;
function getArticleById(callable $cacheGet, callable $cacheSet, callable $getArticleById, int $article_id): Article|null
{
$key = "article-{$article_id}";
$cached = $cacheGet($key);
if (!empty($cached)) {
return $cached;
}
$article = $getArticleById($article_id);
if ($article !== null) {
$cacheSet($key, $article);
}
return $article;
}
}
namespace {
// for this example, just reuse existing class based implementation
$runtimeCache = new RuntimeCache();
$getArticleById = curry(Article\CachedArticleRepository\getArticleById(...),
$runtimeCache->get(...), $runtimeCache->set(...),
curry(Article\SqlRepository\getArticleById(...), $pdo));
var_dump($getArticleById(1));
var_dump($getArticleById(1));
}I do things like your example here when I know at what interval the underlying data is capable of changing. Batch process runs every night at midnight, no big deal, kill and rebuild the cache when it finishes. If you know when the data can change you can rebuild the cache on demand or let it gradually repopulate on its own nicely.
The nasty part of caching is knowing when something changed in the underlying database such that the now invalidated cache entries can be evicted. Seems to me that when it absolutely needs to be up to the second kind of correct, we're best off skipping the "efficiency gain" a cache MAY offer in favor of direct SQL to an actual database connection.
You can spend money on a BIG HOG of a database one time and know precisely how much you will spend to get the speed and reliability your use case demands. If you start trying to solve this with the caching/ORM route you're expense is NOT fixed. Dev Hours vs. Hardware Cost - Im buying hardware almost every time!
I find it usually better to have short timed cache in front of the renderer instead, like caching the HTML or JSON output of a view, then you don't end up with inconsistent data because one table was cached and another wasn't.
If I take the time to flesh out a project and either have a real DBA or at least put on my DBA hat for a day to come up with a proper set of SQL functions, views, etc. that expose everything nicely. That takes quite a while, but the results are good.
ORMs like SQLAlchemy (for Python) or RedBeanPHP (for PHP) can save a lot of time when making MVPs or just gluing together a couple of open source widgets. For these "quick hack" style things I really enjoy RedBeanPHP's fluid schema:
$bean = R::dispense('article');
$bean->title = "Foo";
$bean->body = "Bar! Bar!";
$bean->datePublished = R::isoDateTime();
R::store($bean);
That little snippet of code will automatically create a table `article` and the coorisponding columns `title`, `body`, and `date_published` all with their correct types (and type promotion if needed). If I later decide to add something new to articles I just do this: $bean->whatDoesAiThink = $api->infer($bean->body);
This would automatically add the column `what_does_ai_think` to the schema.All the automatic schema stuff can be disabled (ie. in production) via `R::freeze(true)`