AI‑Powered Serverless Image Processing Pipeline — Part 3: Building a PHP Frontend for Image Upload
In Part 1 we defined the architecture of a truly serverless image‑processing pipeline: a private S3 bucket for raw uploads, an SQS queue that triggers a Lambda function, and a second S3 bucket that holds the processed artefacts. Part 2 walked through the Lambda logic that converts images to WebP, runs moderation via Rekognition, and writes the results to DynamoDB. Now we turn our attention to the user‑facing layer: a lightweight PHP frontend that accepts image files, performs client‑side validation, and hands the data off to our private storage without exposing the bucket to the public.
Below you will find a complete, production‑ready example that ties everything together. It includes a secure upload form, a PHP script that issues a pre‑signed S3 PUT URL via Cognito authentication, and a graceful error‑handling flow. The code is written for PHP 8.2 and uses AWS SDK v3, the AWS Cognito Identity Provider, and the AWS S3 client. All secrets are injected through .env and the vlucas/phpdotenv package.
Why a PHP Frontend?
Although the pipeline itself is serverless, the user interface still needs a small backend to orchestrate authentication and to keep the S3 keys out of the browser. PHP is an excellent fit because:
- It can be deployed as a single Lambda function using the
aws-lambda-phpruntime, or it can live on a traditional web host. - Its ecosystem has mature libraries for AWS, JWT, and HTML rendering.
- WordPress and other CMS platforms often use PHP, so the same code can be dropped into an
includes/admin.phphook for plugin authors.
Below we outline the entire flow:
- Visitor lands on
/upload.phpand is served an HTML form. - Client‑side JavaScript validates the file size and MIME type.
- When the form is submitted, JavaScript calls our
/api/get-presigned.phpendpoint to obtain a signed URL from Cognito. - The browser then streams the file directly to S3 using the signed URL.
- Once the upload finishes, the Lambda function is triggered via an S3 event, processes the image, and writes metadata to DynamoDB.
- The frontend polls an API endpoint (
/api/status.php) for processing status and displays the processed image from the CDN.
Prerequisites
Before you start, ensure the following:
ItemDescription
Two S3 bucketsOne for image-source-yourname-2025 (private) and another for image-processed-yourname-2025 (public via CloudFront).
IAM role for LambdaHas permissions to read from image-source-yourname-2025, write to image-processed-yourname-2025, and write to DynamoDB.
Amazon Cognito Identity PoolConfigured to allow unauthenticated access to the source bucket with PutObject permission.
ComposerFor dependency management.
PHP 8.2 runtimeOn the server or Lambda container.
Project Structure
Assuming a flat web root, the layout looks like this:
/public
├─ index.php (front‑end landing page)
├─ upload.php (upload form)
├─ api
│ ├─ get-presigned.php
│ ├─ status.php
│ └─ config.php
├─ vendor/ (Composer autoload)
├─ .env (environment variables)
└─ composer.json
Environment Variables
Store the following in .env. Never commit this file to version control.
AWS_REGION=eu-west-1
AWS_S3_SOURCE_BUCKET=image-source-yourname-2025
AWS_S3_PROCESSED_BUCKET=image-processed-yourname-2025
AWS_COGNITO_IDENTITY_POOL_ID=eu-west-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
AWS_COGNITO_ROLE_ARN=arn:aws:iam::123456789012:role/Cognito_IdentityPoolUnauth_Role
AWS_DYNAMODB_TABLE=ImageMetadata
CDN_DOMAIN=images.yourdomain.com
MAX_FILE_SIZE=5242880 # 5MB
ALLOWED_MIME_TYPES=image/jpeg,image/png
Composer Dependencies
Create a composer.json that pulls in the AWS SDK and the dotenv package.
{
"require": {
"aws/aws-sdk-php": "^3.400",
"vlucas/phpdotenv": "^5.6"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Run composer install to fetch the libraries.
Configuration File
The api/config.php file loads the environment and exposes a helper class.
load();
class Config
{
public static function awsSdk(): Sdk
{
return new Sdk([
'region' => $_ENV['AWS_REGION'],
'version' => 'latest',
'credentials' => CredentialProvider::defaultProvider(),
]);
}
public static function s3SourceBucket(): string
{
return $_ENV['AWS_S3_SOURCE_BUCKET'];
}
public static function s3ProcessedBucket(): string
{
return $_ENV['AWS_S3_PROCESSED_BUCKET'];
}
public static function cognitoIdentityPoolId(): string
{
return $_ENV['AWS_COGNITO_IDENTITY_POOL_ID'];
}
public static function cognitoRoleArn(): string
{
return $_ENV['AWS_COGNITO_ROLE_ARN'];
}
public static function maxFileSize(): int
{
return (int) $_ENV['MAX_FILE_SIZE'];
}
public static function allowedMimeTypes(): array
{
return explode(',', $_ENV['ALLOWED_MIME_TYPES']);
}
public static function cdnDomain(): string
{
return $_ENV['CDN_DOMAIN'];
}
}
Front‑End Upload Form (upload.php)
The form uses modern JavaScript to obtain a pre‑signed URL and stream the file directly to S3. This eliminates the need to route the file through the PHP server, keeping the upload path truly serverless.
Upload an Image
body {font-family: Arial, sans-serif; margin: 2rem;}
.progress {width: 100%; background: #f0f0f0; border-radius: 5px; overflow: hidden;}
.progress-bar {height: 20px; background: #4caf50; width: 0%;}
Upload an Image for AI Processing
Upload
const form = document.getElementById('uploadForm');
const fileInput = document.getElementById('fileInput');
const status = document.getElementById('status');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
form.addEventListener('submit', async (e) => {
e.preventDefault();
status.textContent = '';
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
const file = fileInput.files[0];
if (!file) { status.textContent = 'Please choose a file.'; return; }
// Client‑side validation
if (file.size > ) {
status.textContent = 'File exceeds maximum size of 5 MB.';
return;
}
if (!.includes(file.type)) {
status.textContent = 'Unsupported file type. Only JPEG and PNG are allowed.';
return;
}
// Request a pre‑signed URL
const response = await fetch('/api/get-presigned.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: `uploads/${Date.now()}-${file.name}`, contentType: file.type })
});
if (!response.ok) {
status.textContent = 'Could not obtain upload URL.';
return;
}
const { url, fields } = await response.json();
// Construct FormData for S3 POST
const formData = new FormData();
Object.entries(fields).forEach(([k, v]) => formData.append(k, v));
formData.append('file', file);
// Upload directly to S3
const uploadResponse = await fetch(url, {
method: 'POST',
body: formData
});
if (!uploadResponse.ok) {
status.textContent = 'Upload failed. Please try again.';
return;
}
status.textContent = 'Upload successful! Processing...';
// Poll for status
const key = fields.key;
const checkStatus = async () => {
const statusResp = await fetch('/api/status.php?key=' + encodeURIComponent(key));
const data = await statusResp.json();
if (data.state === 'processing') {
setTimeout(checkStatus, 2000);
} else if (data.state === 'completed') {
const imgUrl = 'https:///' + data.processedKey;
status.innerHTML = 'Processing complete: ' + imgUrl + '';
} else {
status.textContent = 'Processing failed: ' + data.error;
}
};
checkStatus();
});
Generating a Pre‑Signed URL (api/get-presigned.php)
Because the bucket is private, the client cannot upload directly. Instead, we ask Cognito to provide temporary credentials, then generate a signed URL that allows the browser to perform a POST with multipart/form‑data. The Lambda function that processes the image will only see the original object key, keeping the workflow stateless.
'Invalid request']);
exit;
}
$bucket = Config::s3SourceBucket();
$key = $payload['key'];
$contentType = $payload['contentType'];
// 1. Get temporary credentials from Cognito
$identityPoolId = Config::cognitoIdentityPoolId();
$stsClient = new StsClient(['region' => $_ENV['AWS_REGION'], 'version' => 'latest']);
$credentials = $stsClient->assumeRoleWithWebIdentity([
'RoleArn' => Config::cognitoRoleArn(),
'RoleSessionName' => 'upload-session',
'WebIdentityToken' => $_SERVER['HTTP_AUTHORIZATION'] ?? '',
'DurationSeconds' => 900
]);
$creds = $credentials['Credentials'];
// 2. Generate a pre‑signed POST URL
$s3Client = new S3Client([
'region' => $_ENV['AWS_REGION'],
'version' => 'latest',
'credentials' => [
'key' => $creds['AccessKeyId'],
'secret' => $creds['SecretAccessKey'],
'token' => $creds['SessionToken'],
],
]);
$policy = $s3Client->createPresignedPost([
'Bucket' => $bucket,
'Key' => $key,
'Fields' => [
'Content-Type' => $contentType,
],
'Conditions' => [
['Content-Type', $contentType],
['acl', 'private'],
],
'Expires' => '+10 minutes',
]);
echo json_encode($policy);
In a production scenario you would replace the manual Cognito token extraction with a proper Authorization header or use the AWS Amplify SDK on the client. The example above assumes the client passes a JWT via Authorization. If you are using unauthenticated identities, the assumeRoleWithWebIdentity call can be omitted and you can generate the signed POST directly with an IAM role that has PutObject access to the source bucket.
Processing Status API (api/status.php)
After the upload, the frontend polls this endpoint to determine when the Lambda has finished processing. The Lambda writes a record to DynamoDB with the original key, the processed key, and the processing state. This endpoint reads that record.
'Missing key parameter']);
exit;
}
$dynamo = (new Config::awsSdk())->createDynamoDb();
$result = $dynamo->getItem([
'TableName' => $_ENV['AWS_DYNAMODB_TABLE'],
'Key' => [
'OriginalKey' => ['S' => $key],
],
]);
if (!isset($result['Item'])) {
echo json_encode(['state' => 'processing']);
exit;
}
$item = $result['Item'];
$state = $item['State']['S'];
if ($state === 'completed') {
$processedKey = $item['ProcessedKey']['S'];
echo json_encode(['state' => $state, 'processedKey' => $processedKey]);
} else {
echo json_encode(['state' => $state, 'error' => $item['Error']['S'] ?? '']);
}
Lambda Function (Node.js / Python)
For completeness, here is a brief sketch of the Lambda that runs after the S3 upload event. It pulls the file, converts it to WebP with imagemagick or pillow, runs moderation via Rekognition, and writes metadata to DynamoDB.
import json, boto3, os, subprocess, uuid
from datetime import datetime
s3 = boto3.client('s3')
dynamodb = boto3.resource('dynamodb')
rekognition = boto3.client('rekognition')
SOURCE_BUCKET = os.environ['SOURCE_BUCKET']
DEST_BUCKET = os.environ['DEST_BUCKET']
TABLE_NAME = os.environ['TABLE_NAME']
def lambda_handler(event, context):
# 1. Parse S3 event
record = event['Records'][0]
key = record['s3']['object']['key']
obj = s3.get_object(Bucket=SOURCE_BUCKET, Key=key)
content = obj['Body'].read()
# 2. Moderation
moderation = rekognition.detect_moderation_labels(Image={'Bytes': content})
if moderation['ModerationLabels']:
# Store metadata and abort
store_metadata(key, None, 'moderated', moderation['ModerationLabels'])
return
# 3. Convert to WebP
tmp_input = f'/tmp/{uuid.uuid4()}.jpg'
tmp_output = f'/tmp/{uuid.uuid4()}.webp'
with open(tmp_input, 'wb') as f: f.write(content)
subprocess.run(['convert', tmp_input, '-quality', '80', tmp_output])
# 4. Upload processed image
processed_key = f'webp/{os.path.basename(tmp_output)}'
with open(tmp_output, 'rb') as f:
s3.upload_fileobj(f, DEST_BUCKET, processed_key, ExtraArgs={'ContentType': 'image/webp'})
# 5. Store metadata
store_metadata(key, processed_key, 'completed', None)
def store_metadata(original_key, processed_key, state, error):
item = {
'OriginalKey': original_key,
'State': state,
'ProcessedKey': processed_key or '',
'Timestamp': datetime.utcnow().isoformat(),
}
if error:
item['Error'] = json.dumps(error)
table = dynamodb.Table(TABLE_NAME)
table.put_item(Item=item)
Security Considerations
- Private Buckets: Keep the source bucket private and only expose the processed bucket via CloudFront. Use signed CloudFront URLs if you need to restrict access to specific users.
- IAM Roles: The Lambda should run under an IAM role that only has the permissions it needs. Use least privilege.
- Input Validation: Even though the client performs basic checks, the server must validate MIME types, file size, and image integrity. A malformed image can crash the Lambda.
- Rate Limiting: Throttle uploads per user or per IP to avoid abuse. Cognito’s unauthenticated identities can be throttled via IAM policy or a custom Lambda authorizer. Rekognition Moderation
Originally published at https://artificial-inteligence.phptutorial.co.in
Top comments (0)