rikudou/psr6-dynamo-db
A PSR-6 cache pool backed by AWS DynamoDB. Store and retrieve cache items with TTL support using DynamoDB tables, integrate with any PSR-6-compatible library, and scale caching alongside your AWS infrastructure.
1. Install the package and the SDK. The package does not pull in a DynamoDB client you configure for you — you own the Aws\DynamoDb\DynamoDbClient:
composer require rikudou/psr6-dynamo-db aws/aws-sdk-php
The package ships two classes under Rikudou\DynamoDbCache: DynamoDbCache (PSR-6 pool) and DynamoDbCachePsr16 (PSR-16 wrapper). The PSR-16 wrapper is the one you actually want day-to-day — Laravel developers reach for get/set/delete, not getItem/save.
2. Create the table before writing a line of PHP. Read the package README first for the exact primary key attribute name (it's a single string hash key) and the TTL attribute name it writes. Then:
aws dynamodb create-table \
--table-name app-cache-prod \
--attribute-definitions AttributeName=key,AttributeType=S \
--key-schema AttributeName=key,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
aws dynamodb update-time-to-live \
--table-name app-cache-prod \
--time-to-live-specification "Enabled=true,AttributeName=ttl"
Use PAY_PER_REQUEST to start. A cache is bursty and mostly idle-then-spiky; provisioned capacity is a trap until you have real metrics.
3. Bind it once in a service provider. Do not instantiate the pool per call site — the SDK client is heavy and should be a singleton (this also makes it Octane-friendly):
// config/dynamodb-cache.php
return [
'table' => env('DYNAMODB_CACHE_TABLE', 'app-cache-'.app()->environment()),
'region' => env('AWS_DEFAULT_REGION', 'eu-central-1'),
'prefix' => env('DYNAMODB_CACHE_PREFIX', app()->environment().':'),
'endpoint'=> env('DYNAMODB_CACHE_ENDPOINT'), // LocalStack / dynamodb-local
];
// AppServiceProvider::register()
$this->app->singleton(DynamoDbClient::class, fn () => new DynamoDbClient([
'region' => config('dynamodb-cache.region'),
'version' => 'latest',
'endpoint'=> config('dynamodb-cache.endpoint') ?: null,
'retries' => 2,
'http' => ['connect_timeout' => 0.5, 'timeout' => 1.0],
]));
$this->app->singleton(CacheInterface::class, fn ($app) => new DynamoDbCachePsr16(
new DynamoDbCache(
$app->make(DynamoDbClient::class),
config('dynamodb-cache.table'),
config('dynamodb-cache.prefix'),
)
));
(Confirm the constructor argument order against the README — the table name and key prefix are the two knobs you care about.)
4. First use case — pick the right one. This is a shared, cross-process cache. The natural first fit is a fragment/response cache that must be readable by something other than your PHP app: a Go/Java service, a Lambda, or a second Laravel app in another region. If your only reader is the same Laravel app, Redis or the database driver is cheaper and faster. Inject Psr\SimpleCache\CacheInterface into the service, not the concrete class:
final class PricingFragments
{
public function __construct(private CacheInterface $cache) {}
public function forProduct(string $sku): array
{
$key = 'pricing:'.sha1($sku); // keep under DynamoDB's 2048-byte key limit
if (($hit = $this->cache->get($key)) !== null) {
return $hit;
}
$value = $this->expensiveLookup($sku);
$this->cache->set($key, $value, 900);
return $value;
}
}
Where to look first in the repo: it's a small library (a handful of classes). Read DynamoDbCache end-to-end before trusting it in production — specifically how it serialises values, whether it re-checks expiry on read, and what clear() does. With 7 stars and 0 dependents you are effectively the QA.
Cache-aside decorator over a repository interface. The cleanest pattern is to keep the PSR-16 pool out of your domain code entirely and decorate:
interface ProductRepository { public function find(int $id): ?Product; }
final class CachedProductRepository implements ProductRepository
{
public function __construct(
private ProductRepository $inner,
private CacheInterface $cache,
) {}
public function find(int $id): ?Product
{
$key = "product:v3:{$id}"; // version the key namespace
$cached = $this->cache->get($key);
if ($cached !== null) {
return $cached; // hydrated object, see serialisation note
}
$product = $this->inner->find($id);
if ($product !== null) {
$this->cache->set($key, $product, 3600);
}
return $product;
}
}
Bind the decorator in a provider; the call sites never learn DynamoDB exists. Version the key prefix (v3) so you can invalidate wholesale by bumping a constant instead of scanning.
Two-tier (L1 in-process → L2 DynamoDB). DynamoDB round trips are ~1–5 ms p50 but p99 under throttling/retry is an order of magnitude worse, and every read costs money. Put Laravel's array store in front for hot keys within a request/job:
$key = 'catalog:filters';
$value = Cache::store('array')->remember($key, 60, function () use ($key) {
return $this->dynamo->get($key) ?? $this->rebuildFilters($key);
});
For long-running Octane workers an apcu/static-array tier gives you 10–100× read reduction on the DynamoDB table.
Batch your reads where the protocol allows. If you're hydrating a list of 50 IDs, prefer one getItems() call (PSR-6) over 50 get() calls. Note that PSR-6 batching may map to BatchGetItem (fewer round trips, same RCUs) or may loop internally — check the implementation. Either way, never write has() followed by get(); that's two billed reads for one piece of information. Always get() and null-check.
Use it as the "cold" shared tier behind Redis. Classic shape for multi-service fleets: array (µs) → Redis (sub-ms) → DynamoDB (ms, but globally readable). DynamoDB earns its place here because it's durable, serverless, and a global table can give each region a local replica — Redis can't do that without global replication of its own.
Environment and tenant separation via prefix. One table, many apps, separated by key prefix (prod:app1:, staging:app1:). This saves on table management but muddies CloudWatch metrics and cost allocation. For anything beyond two apps, one table per environment is worth the operational clarity.
Fail-open with a decorator, always. A cache is not a dependency worth a 500. Wrap the PSR-16 instance once:
final class FailSafeCache implements CacheInterface
{
public function __construct(
private CacheInterface $inner,
private \Psr\Log\LoggerInterface $log,
) {}
public function get(string $key, mixed $default = null): mixed
{
try { return $this->inner->get($key, $default); }
catch (\Throwable $e) {
$this->log->warning('dynamo cache read failed', ['key' => $key, 'error' => $e->getMessage()]);
return $default;
}
}
public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool
{
try { return $this->inner->set($key, $value, $ttl); }
catch (\Throwable $e) {
$this->log->warning('dynamo cache write failed', ['key' => $key, 'error' => $e->getMessage()]);
return false;
}
}
// has()/delete()/clear() similarly, has() returning false on error
}
Bind FailSafeCache as the CacheInterface, decorating the real pool. Your app degrades to "slower" instead of "down".
Write-behind / deferred writes for non-critical entries. If you're caching a warm-up artifact, dispatch a queued job to set() it rather than blocking the response on a DynamoDB write. Writes are the expensive half of the cost model.
Laravel's Cache facade will not see this package by default. Cache::store('dynamo') requires a store implementing Illuminate\Contracts\Cache\Store, and PSR-16 is not that contract. You have three options: (a) inject Psr\SimpleCache\CacheInterface directly — recommended; (b) write a thin Store adapter; (c) let Laravel's own driver handle it. Option (b) is where people get burned: Store::increment()/decrement() need atomic semantics DynamoDB can provide only with conditional UpdateItem (the package doesn't expose that), and Store::flush() implies deleting every item in the table. Do not implement flush() as a Scan. Throwing LogicException there is the correct, honest behaviour.
Cache::add(), Cache::lock(), and RateLimiter are unsafe here. They rely on atomic set-if-not-exists. PSR-16 has no add, so any adapter is a get-then-set race. Keep locks and rate limits on Redis or the database driver. This is the single most common way people break production with a new cache driver.
clear() is a Scan. If the package's clear() iterates the whole table, treat it as a loaded gun. With a key prefix you often can't express "delete everything under this prefix" without scanning anyway — DynamoDB has no prefix delete. Rotate your key namespace (bump the v3 segment) instead.
TTL is eventual, not exact. DynamoDB's TTL deletes items in the background, up to ~48 hours after expiry. If you rely only on the TTL attribute, readers can see items long past their logical expiry. Verify the pool stores an expiry field and checks it on read; if it doesn't, store a ['expires_at' => …, 'value' => …] envelope yourself, or accept that you're building a TTL-with-lag store. Also remember the TTL attribute must be enabled on the table via update-time-to-live, and values must be epoch seconds, not milliseconds.
Serialisation will bite you. Check how the package encodes values. If it's PHP serialize(), closures, resources, and Pdo/Closure-bearing objects explode on write. If it's JSON, you silently get arrays back where you wrote objects, and Carbon comes back as a string. Cache scalars and plain arrays; if you must cache models, cache toArray() and rehydrate explicitly. Also watch the 400 KB item limit — an Eloquent collection eager-loaded with relations hits it faster than you think, and a 200 KB write burns ~50 WCUs.
Key length limits. Partition key values max out at 2048 bytes. Long composite keys (URLs, serialised filters) will fail. Hash them: sha1($raw) plus a short human-readable prefix for debuggability (product:a3f9…).
Cost model sanity check. On-demand DynamoDB is billed per request. 100 rps of cache reads for 30 days is ~260 million reads — cheap per unit, not cheap in aggregate, especially if you've got the has()+get() antipattern doubling it. Measure before you scale, and use CloudWatch ConsumedReadCapacityUnits/ConsumedWriteCapacityUnits on the table to find which code paths are actually hot.
Throttling looks like an outage. ProvisionedThroughputExceededException under autoscaling ramp-up, and the SDK's automatic retries add latency spikes. This is why the FailSafeCache decorator and short http.timeout in the client config are non-negotiable. Set retries to 2–3, not the SDK's default 3 with large exponential backoff.
Credentials in Laravel. On ECS/EC2/Lambda use the task/instance role — never AWS_ACCESS_KEY_ID in .env for deployed environments. Locally, aws configure sso writes to the shared credentials file and the SDK picks it up for free. For CI, run amazon/dynamodb-local in Docker, point endpoint at http://127.0.0.1:8000, and create the table in your test bootstrap. Without an endpoint override, php artisan test will try to hit the real AWS account.
Region mismatch is a silent 10× latency tax. A cache in eu-central-1 read from an app in us-east-1 costs cross-region round trips per request. If you're multi-region, use a DynamoDB global table and pin the client region to where the app runs.
Version-pin and own the source. With 0 dependents and 7 stars, pin an exact version ("rikudou/psr6-dynamo-db": "1.x.y"), and be prepared to fork. It's MIT and small — reading the whole thing is an afternoon, and that afternoon is the price of admission. Pay particular attention to whether errors are surfaced or swallowed, and whether getItems() batches.
Extension points to look for. The package likely accepts a key prefix and possibly a fallback/logger. If it doesn't expose a retry or a clock abstraction, you can still wrap it (decorator pattern above) — prefer wrapping to forking, and keep your wrapper PSR-16-compliant so swapping to symfony/cache's DynamoDB adapter or Redis later is a one-line binding change.
Test seam. Because everything hangs off Psr\SimpleCache\CacheInterface, your unit tests get an in-memory fake for free:
$this->app->instance(CacheInterface::class, new \Symfony\Component\Cache\Adapter\Psr16Adapter(
new \Symfony\Component\Cache\Adapter\ArrayAdapter()
));
Reserve real-DynamoDB tests for one or two integration tests that assert the table schema, prefixing, and expiry behaviour line up with what the package expects — those are the assumptions most likely to drift on upgrade.
How can I help you explore Laravel packages today?