Real-time invoice data extraction with PHP and AWS Textract
AWS Textract can extract invoice and receipt fields for a PHP application. Start with a bounded local CLI, then add asynchronous jobs and durable queue handling when needed. OCR returns candidate values, not verified accounting records: check confidence, totals, currencies, and human-review requirements before using results downstream.
Why automate invoice data extraction?
Automation can reduce repeated transcription and make review queues easier to process. The improvement depends on document quality and your workflow; this guide does not establish a percentage reduction in manual work or guarantee real-time latency.
Set up AWS Textract and the PHP SDK v3
Use a maintained PHP 8.x release with Composer and the extensions required by the AWS SDK, including XML and a working HTTPS transport. Install SDK v3 and commit the generated lockfile:
composer require aws/aws-sdk-php:^3
Enable the required AWS services and permissions in one chosen region. Prefer workload IAM roles and the SDK's default credential provider chain over long-lived keys in source code. Actual Textract requests can incur charges. The examples below do not provision accounts, queues, buckets, or IAM policies for you.
Understand the AnalyzeExpense API
AnalyzeExpense is synchronous. StartExpenseAnalysis starts an asynchronous job whose completed
results are retrieved with GetExpenseAnalysis. Do not confuse the two result workflows.
This CLI deliberately accepts only PNG or JPEG files up to 5 MiB, a conservative application limit for the byte-upload path. PDF/TIFF, multipage inputs, and S3-backed processing need their own validation against the current document quotas and API contract. Synchronous PDF/TIFF processing is limited to one page. Check the selected operation's limits rather than assuming every input path accepts the same payload.
Build a synchronous invoice CLI
Save this as invoice.php. It processes a local image and prints structured JSON. It is not a
public upload endpoint: web integration additionally needs authentication, authorization, upload
validation, CSRF protection where relevant, rate limits, and sanitized HTTP responses.
<?php
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/invoice_helpers.php';
use Aws\Textract\TextractClient;
if ($argc !== 2) {
fwrite(STDERR, "Usage: php invoice.php <invoice.png|invoice.jpg>\n");
exit(1);
}
try {
$path = $argv[1];
if (!is_file($path) || !is_readable($path)) {
throw new RuntimeException('Unreadable input');
}
$bytes = file_get_contents($path, false, null, 0, 5 * 1024 * 1024 + 1);
if ($bytes === false || $bytes === '' || strlen($bytes) > 5 * 1024 * 1024) {
throw new RuntimeException('Input exceeds the application limit');
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($bytes);
if (!in_array($mime, ['image/png', 'image/jpeg'], true)) {
throw new RuntimeException('Use PNG or JPEG');
}
$textract = new TextractClient([
'region' => getenv('AWS_REGION') ?: 'us-east-1',
'version' => '2018-06-27',
'http' => ['connect_timeout' => 5, 'timeout' => 30],
'retries' => 2,
]);
$result = $textract->analyzeExpense(['Document' => ['Bytes' => $bytes]]);
echo json_encode(parseExpense($result->toArray()), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), "\n";
} catch (Throwable $error) {
fwrite(STDERR, "Invoice analysis failed. Check the image, size, credentials, region, and permissions.\n");
exit(1);
}
Use php invoice.php invoice.png after adding the helper below. The SDK serializes the raw image
bytes; do not base64-encode them again. JSON output can contain sensitive invoice data, so do not
send it to public logs. AWS SDK results are objects: call toArray() before passing them to a helper
that explicitly accepts an array.
Implement asynchronous processing with SNS + SQS
The following integration fragments belong in your authenticated job service. They assume a configured S3 client, Textract client, bucket, verified local input, SNS topic, and publishing role. Persist ownership and job state in your application; these are not standalone HTTP handlers.
1 · upload the document to S3
Use an application-generated key and close the source stream even when the upload fails:
$s3Key = 'invoices/' . bin2hex(random_bytes(16)) . '.pdf';
$stream = fopen($localPath, 'rb');
if ($stream === false) {
throw new RuntimeException('Cannot open validated invoice');
}
try {
$s3->putObject(['Bucket' => $bucket, 'Key' => $s3Key, 'Body' => $stream]);
} finally {
fclose($stream);
}
Validate the PDF's size, page count, encryption, and type before this stage. The bucket must be in the Textract region, with access and encryption permissions appropriate to your workload.
2 · kick off StartExpenseAnalysis
Create and persist an application job and its idempotency token before calling Textract. Reuse that token with the same parameters when retrying a failed response:
$start = $textract->startExpenseAnalysis([
'DocumentLocation' => ['S3Object' => ['Bucket' => $bucket, 'Name' => $s3Key]],
'NotificationChannel' => ['SNSTopicArn' => $snsTopicArn, 'RoleArn' => $roleArn],
'ClientRequestToken' => $persistedRequestToken,
]);
$jobId = $start->get('JobId');
if (!is_string($jobId) || $jobId === '') {
throw new RuntimeException('Textract returned no job ID');
}
Persist the returned job ID with the owner, S3 key, region, and pending status. Reconcile a retry against that record so a lost response does not create unrelated duplicate work.
3 · configure the notification channel
Create the SNS topic and SQS subscription in the appropriate region. Set RawMessageDelivery=true
for the decoder below. Restrict the queue policy to the expected SNS topic and account, and give
Textract's publishing role the narrowly scoped permissions and trust policy required by AWS.
A role with sns:Publish alone is not the complete configuration.
4 · run a long-poll worker
Add this function to invoice_helpers.php. It performs one long poll. A supervisor can invoke it
repeatedly with a configured Aws\Sqs\SqsClient, queue URL, and your durable event handler.
function consumeExpenseMessages(Aws\Sqs\SqsClient $sqs, string $queueUrl, callable $persistEvent): int
{
$response = $sqs->receiveMessage([
'QueueUrl' => $queueUrl, 'MaxNumberOfMessages' => 10, 'WaitTimeSeconds' => 20,
]);
$failures = 0;
foreach ($response['Messages'] ?? [] as $message) {
try {
// Raw SNS delivery contains the Textract event directly, not a Message envelope.
$event = json_decode($message['Body'], true, 512, JSON_THROW_ON_ERROR);
if (!is_array($event) || ($event['API'] ?? null) !== 'StartExpenseAnalysis'
|| !is_string($event['JobId'] ?? null) || $event['JobId'] === ''
|| !in_array($event['Status'] ?? null, ['SUCCEEDED', 'FAILED', 'PARTIAL_SUCCESS'], true)) {
throw new RuntimeException('Invalid Textract event');
}
$persistEvent($event);
$sqs->deleteMessage([
'QueueUrl' => $queueUrl, 'ReceiptHandle' => $message['ReceiptHandle'],
]);
} catch (Throwable $error) {
$failures++;
error_log('Invoice event processing failed; message was not acknowledged');
}
}
return $failures;
}
The callback is a required application boundary. It must look up the known job, verify ownership and expected source information, retrieve results when appropriate, and commit a durable, idempotent state change before returning. It must throw on failed persistence. An empty callback would acknowledge and lose work. Failed or partial Textract jobs need a durable review/failure state, not a successful invoice record.
Set the SDK HTTP timeout longer than the 20-second poll. Use a visibility timeout covering processing and persistence, extend it for longer work, and configure a dead-letter queue. Duplicate messages and an acknowledgment failure after a successful commit must be safe to replay.
Extract and structure invoice data
Save this helper in invoice_helpers.php, beginning the file with <?php. It preserves repeated
field types as separate entries and retains confidence rather than silently overwriting values in
a type-keyed map:
function parseExpense(array $result): array
{
return array_map(function (array $document): array {
$fields = static fn (array $field): array => [
'type' => $field['Type']['Text'] ?? null,
'text' => $field['ValueDetection']['Text'] ?? null,
'confidence' => $field['ValueDetection']['Confidence'] ?? null,
];
$groups = [];
foreach ($document['LineItemGroups'] ?? [] as $group) {
$rows = [];
foreach ($group['LineItems'] ?? [] as $row) {
$rows[] = array_map($fields, $row['LineItemExpenseFields'] ?? []);
}
$groups[] = ['index' => $group['LineItemGroupIndex'] ?? null, 'rows' => $rows];
}
return [
'expense_index' => $document['ExpenseIndex'] ?? null,
'summary' => array_map($fields, $document['SummaryFields'] ?? []),
'line_item_groups' => $groups,
];
}, $result['ExpenseDocuments'] ?? []);
}
Sample response snippet
[
{
"expense_index": 1,
"summary": [
{ "type": "VENDOR_NAME", "text": "Example Supplies", "confidence": 98.5 },
{ "type": "TOTAL", "text": "125.00", "confidence": 97.2 }
],
"line_item_groups": []
}
]
This is an illustrative projection, not the complete AWS response. Retain source responses under an appropriate data policy if later review requires geometry, currency, page references, or labels.
Build a lightweight dashboard
Show pending, completed, failed, and review-needed states. Escape OCR text when rendering it and apply CSV formula-injection protection to spreadsheet exports. Invoice totals are untrusted text: parse currencies and decimal conventions explicitly, reconcile line items, and require appropriate approval before an ERP mutation or payment.
Error handling and retry strategies
| Layer | Required behavior |
|---|---|
| SDK | Bound retries and request timeouts; distinguish configuration failures from retryable errors |
| Queue | Acknowledge only after durable processing; tolerate duplicates |
| Persistence | Enforce job identity and idempotent writes |
| Partial results | Preserve warnings and route to review instead of claiming complete success |
| Logs | Keep invoice text, credentials, provider payloads, and raw stack traces out of public logs |
Performance optimization and cost considerations
Choose synchronous versus asynchronous processing according to document limits and response-time needs. Monitor actual request rates and processing latency rather than relying on fixed default quota numbers. Configure storage retention and access policies for sensitive invoices. Keep retries bounded and review billing metrics for unexpected duplicate work.
Security best practices
Authenticate uploads, authorize each job and result, constrain sizes and formats, and use private storage. Restrict SNS/SQS and encryption-key policies to the participating resources. Do not make the Textract client or event callback available as an unauthenticated public endpoint. Service errors should become sanitized application statuses, not raw provider responses.
Troubleshooting cheat-sheet
| Symptom | Checks |
|---|---|
| Access denied | Caller permissions, publishing-role trust, SNS/SQS policies, and KMS access |
| No events | Topic subscription, queue policy, region, and raw-delivery setting |
| Empty or uncertain fields | Source readability, model limitations, and confidence-based review |
| Repeated jobs | Idempotency token persistence and queue replay handling |
Handle multi-page invoices and batching
Retrieve all result pages after success. This generator omits NextToken on the first request and
rejects a repeated token or non-successful job status. Add it to the same helper file:
function expensePages(Aws\Textract\TextractClient $textract, string $jobId): Generator
{
$request = ['JobId' => $jobId, 'MaxResults' => 20];
$seen = [];
do {
$result = $textract->getExpenseAnalysis($request);
if (($result['JobStatus'] ?? null) !== 'SUCCEEDED') {
throw new RuntimeException('Expense analysis is not fully successful');
}
yield $result->toArray();
$token = $result['NextToken'] ?? null;
if ($token === null) break;
if (!is_string($token) || $token === '' || isset($seen[$token])) {
throw new RuntimeException('Invalid expense pagination token');
}
$seen[$token] = true;
$request['NextToken'] = $token;
} while (true);
}
Persist page results with their job identity, ExpenseIndex, and line-item group indexes. Do not
assume each pagination response is a separate invoice or mark a job complete before the generator
finishes. If a later page fails, retry from a durable checkpoint or replace staged results
idempotently; previously yielded pages are not proof of a complete job.
References
Next steps
Build a verified review workflow around the extracted fields before automating downstream business actions. For managed preprocessing and document conversion, explore Transloadit's Document Processing service.
