kitar/laravel-dynamodb
Seamlessly integrate Amazon DynamoDB into Laravel with Eloquent-style models, a fluent query builder, and local DynamoDB support for development and testing. Ideal for scalable, serverless-friendly NoSQL applications.
kitar/laravel-dynamodb is the actively maintained fork of the older baopham/laravel-dynamodb. It gives you an Eloquent-style model and query builder on top of the AWS SDK's DynamoDbClient, so you can write User::find('u1') and User::where('id', '=', 'u1')->get() against DynamoDB tables. Most of the search traffic for this package lands on the archived baopham README, so treat that as the primary reference and the kitar repo as the current source of truth.
Install and publish config
composer require kitar/laravel-dynamodb
php artisan vendor:publish --tag=config # writes config/dynamodb.php
Fill in the connection — either from standard AWS env vars or the package config:
// config/dynamodb.php
return [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'endpoint' => env('DYNAMODB_ENDPOINT'), // set for DynamoDB Local
'token' => env('AWS_SESSION_TOKEN'),
];
Define your first model
use Kitar\Dynamodb\Model;
class User extends Model
{
protected $table = 'users';
protected $primaryKey = 'id'; // hash key
protected $sortKey = 'created_at'; // range key (optional)
protected $keyType = 'S';
protected $sortKeyType = 'N';
protected $fillable = ['id', 'created_at', 'email', 'name'];
}
First useful case: a session / profile lookup by key
User::create([
'id' => 'u_123',
'created_at' => now()->timestamp,
'email' => '[email protected]',
'name' => 'Ada',
]);
$user = User::find(['u_123', 1717000000]); // composite key: [hash, range]
If the table doesn't exist yet, create it outside of Laravel — the package does not ship migrations or a table builder. Use the AWS Console, aws dynamodb create-table, Terraform, or CloudFormation.
Local dev: run amazon/dynamodb-local in Docker, point DYNAMODB_ENDPOINT=http://localhost:8000, and use dummy credentials. AWS_SDK will happily ignore them against a local endpoint.
Composite keys and lookups
$primaryKey must match the partition (hash) key attribute name.$sortKey is optional; it enables range queries and indexed order.find() takes a scalar for a hash-key-only table and an array [$hash, $range] for composite keys. If you get "ValidationException: The provided key element does not match the schema", you've mismatched one of these.Querying: partition key first, then sort key
// hash-key equality is required to be a Query, not a Scan
$result = User::where('id', '=', 'u_123')
->where('created_at', '>', 1716000000)
->where('created_at', '<', 1719000000)
->orderBy('created_at', 'desc') // maps to ScanIndexForward = false
->limit(25)
->get();
Additional where() clauses on non-key attributes become a FilterExpression. That is still useful — filters run after the read and reduce what's returned — but they do not reduce consumed read capacity and do not turn a partition scan into a cheap lookup. Design the key (or a GSI) around the access pattern you actually need.
Secondary indexes
Select the index on the builder before the key conditions; the package exposes an index selector (see the README for the exact method name in your version). Everything else behaves like the base table, with the caveat that GSIs are eventually consistent.
Pagination and scanning
$page = User::where('id', '=', 'u_123')->paginate(20);
paginate() uses ExclusiveStartKey internally, which is why it works — but it does not know the total row count without a Scan. Avoid count() in listings and UI badges; cache a separately maintained counter or accept "next page" style pagination. For full-table work, use Model::scan() and chunk it rather than ->get() on an unbounded scan.
Conditional writes and optimistic locking
DynamoDB's superpower is conditional puts, so use them where the package allows. On save()/update() you can attach key conditions to get CAS semantics:
User::where('id', '=', 'u_123')
->where('version', '=', $currentVersion)
->update(['name' => $newName, 'version' => $currentVersion + 1]);
Wrap it in a retry loop — a ConditionalCheckFailedException from the SDK means "someone else won, reload and retry". This is the pattern to build idempotent job handlers and webhook consumers on.
Partial updates vs. full replacement
This is the single biggest day-to-day difference from SQL:
save() on an existing model performs a PutItem, which replaces the whole item (any attributes you didn't load are wiped).->update([...]) performs UpdateItem and patches only the named attributes.Rule of thumb: for "change one field" flows (views counters, status flips, last_login_at), use ->update() and never save(). For "I have the full document in hand", save() is fine.
Escaping to the AWS SDK
Anything the builder doesn't model — BatchWriteItem (25 items), BatchGetItem (100 keys), TransactWriteItems, TransactGetItems, TTL, streams, DescribeTable — goes through the Aws\DynamoDb\DynamoDbClient. Resolve it from the container (or from the model's connection) and keep the raw calls in small, dedicated service classes. Mixing raw client calls and Eloquent calls in the same controller is the usual route to inconsistent retries and inconsistent data shapes.
Model events and observers
Eloquent events (saving, saved, deleting) fire, so observers for audit fields, updated_at stamping, or cache invalidation do work. Just keep in mind that update() on the builder bypasses model events (same as in Eloquent/Query Builder), so don't rely on observers to run for every write.
Testing
Spin up DynamoDB Local per test suite, create tables once in setUpBeforeClass, and truncate/Scan+DeleteItem between tests. There's no RefreshDatabase equivalent for DynamoDB, and there is no schema to migrate — you're responsible for table lifecycle in both local and CI.
where ≠ SQL WHERE. First condition must be equality on the hash key. Everything else is either a range condition on the sort key or a post-read filter. If you're writing orWhere across partition keys, you actually want multiple queries and a merge in PHP.LIKE doesn't exist. begins_with is the only prefix primitive; there's no substring search. For real search, export to OpenSearch/Postgres."" as an attribute value. Normalize incoming form data ($value === '' ? null : $value) or strip nulls before save; otherwise you'll get a ValidationException.$casts and don't lose precision. For IDs or money that exceed PHP int, keep them as strings and cast carefully — DynamoDB numbers have 38 digits of precision and nothing more.find() requires the exact key. With a composite key, passing only the hash value silently errors. Use find([$hash, $range]) or query with where() on both keys.paginate() totals are expensive. The paginator's total() is not a real count. Don't show "1,234 results" in the UI unless you maintain a counter. Same for ->count() on any filter-based query.Query/Scan pages max 1 MB. Pagination in the package is ExclusiveStartKey-based, so loop on LastEvaluatedKey for exports rather than assuming get() returned everything.save() overwrites; update() patches. Covered above, but it's worth repeating because it's the bug you'll actually ship. A "partial" save() on a model with only id and one field set will wipe the rest of the item.ProvisionedThroughputExceededException, and GSIs throttle independently of the base table. Enable the SDK's built-in retry mode (retries / adaptive mode) in config/dynamodb.php, and consider on-demand capacity for spiky workloads.$timestamps = true, created_at/updated_at are written as attributes — they must be declared in the table schema if you query or index on them, and updated_at only moves when Eloquent writes run (not on builder update()).$sortKeyType defaults are easy to get wrong; a numeric created_at with $sortKeyType = 'S' produces a ValidationException that's hard to read. Set 'S' or 'N' explicitly and keep it consistent with your writes.UserRepository behind interface methods keeps the AWS SDK escape hatches out of your controllers and makes the eventual "move this table to Postgres" refactor mechanical rather than archaeological.How can I help you explore Laravel packages today?