microsoft/azure-storage-table
PHP client library for Microsoft Azure Table storage. Create, read, update, and delete entities; run queries and batch operations; authenticate with account keys or SAS tokens. A straightforward SDK for NoSQL table data in Azure.
Read the parent repo, not this one. microsoft/azure-storage-table is a read-only split of azure-storage-php; the archived stub repo exists only so Composer can resolve microsoft/azure-storage-table. All docs, samples, and issue history live upstream. Treat this as a legacy SDK you are adopting knowingly: last release 2022-09-02, archived, zero dependents — you will be the one finding the bugs.
Install and bind once.
composer require microsoft/azure-storage-table
There is no service provider, facade, or config file — it is a plain Guzzle-backed SDK. Wire it yourself:
// config/services.php
'azure_table' => [
'connection_string' => env('AZURE_TABLE_CONNECTION_STRING'),
'table' => env('AZURE_TABLE_NAME', 'appdata'),
],
// app/Providers/AppServiceProvider.php
use MicrosoftAzure\Storage\Table\TableRestProxy;
$this->app->singleton(TableRestProxy::class, fn () => TableRestProxy::createTableService(
config('services.azure_table.connection_string')
));
Connection string form: DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net. For local dev, Azurite supports the Table service — UseDevelopmentStorage=true and run azurite --tableHost 127.0.0.1.
First use case: append-only audit/event log. Table Storage is a schemaless, cheap, key-addressed store, which makes it a natural fit for "write a lot, query by tenant + time" data you do not want in a relational table. It is a poor fit for anything needing joins, aggregation, or ad-hoc filtering.
use MicrosoftAzure\Storage\Table\TableRestProxy;
$proxy = app(TableRestProxy::class);
$proxy->createTable('auditlog'); // 409 TableAlreadyExists is fine; swallow it
$proxy->insertEntity('auditlog', [
'PartitionKey' => 'tenant-42',
'RowKey' => (string) Str::uuid(),
'Event@type' => 'Edm.String',
'Event' => 'user.login',
'OccurredAt@type' => 'Edm.DateTime',
'OccurredAt' => now()->utc()->format('Y-m-d\TH:i:s\Z'),
'ActorId@type' => 'Edm.Int64',
'ActorId' => (string) $user->id, // Int64 must be a string
]);
Wrap it in a repository, never call TableRestProxy from controllers. The SDK's arrays-in/objects-out API leaks into everything; hide it behind one class so swapping to Cosmos DB or a real DB later is a single-file change.
namespace App\Storage;
use MicrosoftAzure\Storage\Table\Models\QueryEntitiesOptions;
use MicrosoftAzure\Storage\Table\Models\Filters\Filter;
use MicrosoftAzure\Storage\Table\TableRestProxy;
class TableStore
{
public function __construct(
private TableRestProxy $proxy,
private string $defaultTable,
) {}
public function put(string $table, array $entity): void
{
$this->proxy->insertOrMergeEntity($table, $entity);
}
public function forPartition(string $table, string $pk, int $top = 100, ?string $token = null): array
{
$options = new QueryEntitiesOptions();
$options->setFilter(Filter::applyEq(
Filter::applyPropertyName('PartitionKey'),
Filter::applyConstant($pk, 'Edm.String')
));
$options->setTop($top);
if ($token) {
$options->setContinuationToken($token);
}
return $this->proxy->queryEntities($table, $options)->getEntities();
}
}
Filters are typed object graphs, not raw OData strings — build them with the Filter::apply* helpers (applyEq, applyGe, applyAnd, applyOr, applyNot, applyPropertyName, applyConstant). The escape hatch is in vendor/microsoft/azure-storage-table/src/Table/Models/Filters/ if you need something the helpers don't cover.
Shape your keys for the query you will actually run. There are no secondary indexes; anything not on PartitionKey/RowKey is a scan. Two patterns carry most of the weight:
PartitionKey = "tenant-{id}", RowKey = "{reverse-ticks}-{uuid}" so newest-first reads are a single partition range scan.PartitionKey = "tenant-{id}:{type}" to co-locate heterogeneous records and keep batches single-partition.Batch writes are your transaction unit. Up to 100 operations, all in the same table and same PartitionKey, atomic and no duplicate keys inside one batch:
use MicrosoftAzure\Storage\Table\Models\BatchOperations;
$batch = new BatchOperations();
foreach ($rows as $row) {
$batch->addInsertOrMergeEntity('auditlog', $row);
}
$results = $this->proxy->batch($batch); // per-op status, not all-or-nothing at the HTTP level
Optimistic concurrency via ETag. getEntity returns an entity with getEtag(); pass it to UpdateEntityOptions::setIfMatch($etag) and catch the 412 conflict. This is the only locking primitive you get.
Queue it. Writes to Table Storage are network-bound and occasionally throttled (503 with Retry-After). Push audit writes onto a Laravel queue job rather than blocking a request, and let the SDK/common layer's retry middleware handle transient 5xx.
Pagination is explicit. A query returns at most 1000 entities and a continuation token in getContinuationToken(). Feed that back through setContinuationToken() in a loop — do not pretend setTop() gives you a complete result set.
composer.json and check composer why guzzlehttp/guzzle after install; a Laravel upgrade that bumps Guzzle is your most likely breakage.Edm.String property needs a sibling "Name@type" key. Edm.Int64 must be a string; Edm.DateTime must be ISO-8601 UTC (Y-m-d\TH:i:s\Z, millisecond precision only — sub-ms precision silently truncates); Edm.Binary is base64.Timestamp is system-managed. Do not write it, do not filter on it for ordering — use your own OccurredAt column.PartitionKey/RowKey max 1 KB and cannot contain / \ # ? or control characters. UUIDs are fine; raw user input is not — hash or base64 it.php artisan command that deletes by partition, or an ExpiresAt column plus a sweeper. Plan for it on day one.PartitionKey + RowKey) is one cheap transaction; a filter on a non-key property is a full partition scan billed per entity examined. Never expose a user-supplied filter to this API.contains/like. Prefix matching requires ge/lt range pairs on the key.insertEntity throws on duplicate keys (409) — use insertOrMergeEntity / insertOrReplaceEntity for idempotent writes, which matters for retried queue jobs.mergeEntity updates only supplied properties; updateEntity (replace) wipes everything you omit. Pick deliberately.TableAlreadyExists at boot, never in a request path — createTable on every page load is wasted RU and a latency tax.Top > 1000, different Timestamp behavior, RU throttling surfaced as 429 rather than 503).How can I help you explore Laravel packages today?