Real-time invoice data extraction with PHP and AWS Textract
Use AWS Textract’s synchronous AnalyzeExpense operation to turn a local invoice image into JSON
candidates for review. The PHP CLI below keeps repeated fields, line items, and numeric confidence.
It does not validate an invoice’s arithmetic or authorize a payment.
Set up AWS Textract and the PHP SDK v3
You need PHP with cURL, Fileinfo, SimpleXML, and GD with PNG/JPEG support, plus separately installed Composer and Bash for the shell commands. This example was tested with PHP 8.5.11 and Composer 2.10.3. Use a maintained PHP 8.5 release; the SDK itself supports PHP 8.1 and later, but that does not establish compatibility for every dependency in this installation.
Configure AWS credentials using the
SDK’s credential provider chain.
Your caller needs textract:AnalyzeExpense permission in the selected region. This byte-upload
example uses no S3 bucket, SNS topic, or SQS queue. Each service request can incur a charge.
Paste this from the directory where you want a new invoice-ocr project. The subshell keeps your
current directory and shell options intact. An existing destination stops setup before installation.
The package versions pin the tested SDK and the Guzzle transport used directly by the example.
The local manifest and cleared Composer path overrides keep installation inside this project.
(
for tool in php composer; do
command -v "$tool" >/dev/null || { printf 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }
done &&
php -r 'foreach (["curl", "fileinfo", "SimpleXML", "gd"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: $extension\n"); exit(1); } } if (!(imagetypes() & IMG_PNG) || !(imagetypes() & IMG_JPG)) { fwrite(STDERR, "GD needs PNG and JPEG support\n"); exit(1); }' &&
unset COMPOSER COMPOSER_VENDOR_DIR COMPOSER_BIN_DIR &&
mkdir invoice-ocr &&
cd invoice-ocr &&
printf '{"require": {}, "config": {"vendor-dir": "vendor", "bin-dir": "vendor/bin"}}\n' > composer.json &&
composer require aws/aws-sdk-php:3.400.1 guzzlehttp/guzzle:8.2.0 &&
php -r 'require "vendor/autoload.php"; if (!class_exists(Aws\Textract\TextractClient::class) || !class_exists(GuzzleHttp\Handler\CurlHandler::class)) { exit(1); }'
)
Keep composer.json and composer.lock. If installation fails after directory creation, fix the
reported prerequisite or network problem and retry inside that directory, preserving existing files:
(
unset COMPOSER COMPOSER_VENDOR_DIR COMPOSER_BIN_DIR &&
cd invoice-ocr &&
{ [ -f composer.json ] || printf '{"require": {}, "config": {"vendor-dir": "vendor", "bin-dir": "vendor/bin"}}\n' > composer.json; } &&
composer require aws/aws-sdk-php:3.400.1 guzzlehttp/guzzle:8.2.0 &&
php -r 'require "vendor/autoload.php"; if (!class_exists(Aws\Textract\TextractClient::class) || !class_exists(GuzzleHttp\Handler\CurlHandler::class)) { exit(1); }'
)
Understand the AnalyzeExpense API
AnalyzeExpense
returns ExpenseDocuments, with SummaryFields and nested LineItemGroups. Field classifications
are inferred: a vendor name may be absent even when useful totals and items are recognized.
This CLI accepts one nonempty local PNG or JPEG containing at most 5,000,000 bytes, with each dimension at most 5,000 pixels and at most 10,000,000 pixels overall. These are application bounds, including trailing file bytes, rather than a claim about AWS’s maximum payload. GD must decode the image without a reported warning. Decoding is a readability check, not proof that every pixel of a damaged image is intact. PDF, TIFF, URLs, and multipage processing are outside this CLI.
Build a synchronous invoice CLI
Save this complete program as invoice-ocr/invoice.php. It sends the original image bytes and prints
JSON only after analysis and parsing succeed. The SDK handles the
byte serialization;
do not base64-encode the input yourself.
<?php
use Aws\Handler\Guzzle\GuzzleHandler;
use Aws\Textract\TextractClient;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\HandlerStack;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
if ($argc !== 2) {
fwrite(STDERR, "Usage: php invoice.php <local-invoice.png|local-invoice.jpg>\n");
exit(1);
}
function parseExpense(array $result): array
{
return array_map(function (array $document): array {
$field = static fn (array $value): array => [
'type' => $value['Type']['Text'] ?? null,
'text' => $value['ValueDetection']['Text'] ?? null,
'confidence' => $value['ValueDetection']['Confidence'] ?? null,
];
$groups = [];
foreach ($document['LineItemGroups'] ?? [] as $group) {
$rows = [];
foreach ($group['LineItems'] ?? [] as $row) {
$rows[] = array_map($field, $row['LineItemExpenseFields'] ?? []);
}
$groups[] = ['index' => $group['LineItemGroupIndex'] ?? null, 'rows' => $rows];
}
return [
'expense_index' => $document['ExpenseIndex'] ?? null,
'summary' => array_map($field, $document['SummaryFields'] ?? []),
'line_item_groups' => $groups,
'requires_review' => true,
];
}, $result['ExpenseDocuments'] ?? []);
}
try {
foreach (['curl', 'fileinfo', 'SimpleXML', 'gd'] as $extension) {
if (!extension_loaded($extension)) {
throw new RuntimeException('Required PHP extension unavailable');
}
}
set_error_handler(static function (int $severity, string $message): never {
throw new RuntimeException('Local image or dependency could not be read');
});
$path = $argv[1];
if (str_contains($path, '://') || !is_file($path) || !is_readable($path)) {
throw new RuntimeException('Use a readable local file');
}
$bytes = file_get_contents($path, false, null, 0, 5_000_001);
if ($bytes === false || $bytes === '' || strlen($bytes) > 5_000_000) {
throw new RuntimeException('Use 1 to 5,000,000 bytes');
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($bytes);
if (!in_array($mime, ['image/png', 'image/jpeg'], true)) {
throw new RuntimeException('Use PNG or JPEG');
}
$size = getimagesizefromstring($bytes);
if ($size === false || $size[0] > 5_000 || $size[1] > 5_000
|| $size[0] * $size[1] > 10_000_000) {
throw new RuntimeException('Image dimensions exceed the application bounds');
}
$image = imagecreatefromstring($bytes);
if ($image === false) {
throw new RuntimeException('Image cannot be decoded');
}
unset($image);
require __DIR__ . '/vendor/autoload.php';
$textract = new TextractClient([
'region' => getenv('AWS_REGION') ?: 'us-east-1',
'version' => '2018-06-27',
'http_handler' => new GuzzleHandler(new Client([
'handler' => HandlerStack::create(new CurlHandler()),
])),
'http' => ['connect_timeout' => 5, 'timeout' => 30],
'retries' => 2,
]);
$result = $textract->analyzeExpense(['Document' => ['Bytes' => $bytes]]);
$documents = parseExpense($result->toArray());
if ($documents === []) {
throw new RuntimeException('No expense documents returned');
}
echo json_encode($documents, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), "\n";
} catch (Throwable $error) {
fwrite(STDERR, "Invoice analysis failed. Check local file readability, PNG/JPEG decoding, byte and pixel bounds, PHP extensions, SDK installation, credentials, region, and AnalyzeExpense permission.\n");
exit(1);
}
Put your invoice image in the project, then run this from the directory containing invoice-ocr:
(cd invoice-ocr && php invoice.php ./invoice.png)
For JPEG, pass ./invoice.jpg instead. Paths containing spaces need shell quotes. Use ./ before a
filename starting with a hyphen. The program writes only to stdout and stderr; it leaves the input
and any existing result files alone. Avoid redirecting stdout over a previous result before checking
success, since the shell can truncate that file even when analysis fails. Invoice JSON can contain
sensitive data, so keep it out of public logs.
Extract and structure invoice data
The parser projects every summary and line-item candidate into type, text, and confidence,
keeping their order and document/group boundaries. Arrays preserve repeated types that a map keyed
by TOTAL, NAME, or OTHER would overwrite. Missing properties remain null; confidence is the
value detector’s score, not an accounting guarantee.
Sample response snippet
This illustrative document has two candidates of the same type. Actual categories, values, and scores vary. The projection omits geometry, labels, currency metadata, and source page references.
[
{
"expense_index": 1,
"summary": [
{ "type": "TOTAL", "text": "125.00", "confidence": 97.2 },
{ "type": "TOTAL", "text": "125.00 USD", "confidence": 88.5 }
],
"line_item_groups": [],
"requires_review": true
}
]
Before importing candidates into accounts payable, inspect the source image, interpret decimal and currency conventions, reconcile totals with line items, and record a review decision. Escape OCR text in a dashboard. If review needs coordinates or page references, retain the full SDK response under your data policy rather than relying on this small projection. For preprocessing and document conversion, see our Document Processing service.
Error handling and retry strategies
The cURL handler enforces a five-second connection timeout and a 30-second timeout per attempt. The SDK retry setting allows two retries after the first attempt. Backoff, credential lookup, and local processing are additional time, so these settings are not a whole-command deadline. A slow connection can fail even for a small image. Check the connection and retry deliberately; repeated calls can cost money.
Keep HandlerStack::create: Guzzle’s
default middleware
turns HTTP failures into rejected promises that the SDK can interpret. Removing that stack changes
the service-error path. A refused request exits nonzero with empty stdout and a sanitized diagnostic.
Do not treat an empty file created by shell redirection as a successful invoice result.
Troubleshooting cheat-sheet
| Symptom | Next check |
|---|---|
| Setup stopped before creating the project | Check each required executable and PHP extension |
| Composer failed after project creation | Fix its reported error, then use the installation retry above |
| Local image refused | Check nonempty bytes, actual PNG/JPEG content, decoding, and both pixel bounds |
| Analysis failed after local checks | Check credentials, region, network access, and textract:AnalyzeExpense permission |
| An expected field is absent | Inspect the source and other candidates; categories and recall are not guaranteed |
| Values look plausible | Keep requires_review; confidence does not prove totals, currency, or payment correctness |
Implement asynchronous processing with SNS + SQS
For multipage invoices or a job that should outlive its caller, choose the
asynchronous expense workflow.
StartExpenseAnalysis reads from S3; GetExpenseAnalysis retrieves completed results, with
pagination. AWS’s asynchronous guide
describes SNS completion notifications and an SQS consumer. That is a separate application: provision
its resources and permissions, persist job ownership and idempotency state, and acknowledge messages
only after durable processing. The synchronous CLI above does not implement that workflow.
