AI-Powered Serverless Image Processing Pipeline — Part 3: Building a PHP Frontend for Image Upload

⏱ 8 min read  |  ~1624 words

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-php runtime, 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.php hook for plugin authors.

Below we outline the entire flow:

  1. Visitor lands on /upload.php and is served an HTML form.
  2. Client‑side JavaScript validates the file size and MIME type.
  3. When the form is submitted, JavaScript calls our /api/get-presigned.php endpoint to obtain a signed URL from Cognito.
  4. The browser then streams the file directly to S3 using the signed URL.
  5. Once the upload finishes, the Lambda function is triggered via an S3 event, processes the image, and writes metadata to DynamoDB.
  6. 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:

Item Description
Two S3 buckets One for image-source-yourname-2025 (private) and another for image-processed-yourname-2025 (public via CloudFront).
IAM role for Lambda Has permissions to read from image-source-yourname-2025, write to image-processed-yourname-2025, and write to DynamoDB.
Amazon Cognito Identity Pool Configured to allow unauthenticated access to the source bucket with PutObject permission.
Composer For dependency management.
PHP 8.2 runtime On 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.

<?php
require_once __DIR__ . '/../vendor/autoload.php';

use Aws\Sdk;
use Aws\Credentials\CredentialProvider;
use Dotenv\Dotenv;

$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->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.

<?php
require_once __DIR__ . '/api/config.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Upload an Image</title>
<style>
    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%;}
</style>
</head>
<body>
<h1>Upload an Image for AI Processing</h1>
<form id="uploadForm">
    <input type="file" name="file" id="fileInput" accept="image/*" required>
    <button type="submit">Upload</button>
</form>
<div id="status"></div>
<div id="progressContainer" style="display:none;">
    <div class="progress"><div class="progress-bar" id="progressBar"></div></div>
</div>
<script>
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 > <?= Config::maxFileSize() ?>) {
        status.textContent = 'File exceeds maximum size of 5 MB.';
        return;
    }
    if (!<?= json_encode(Config::allowedMimeTypes()) ?>.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://<?= Config::cdnDomain() ?>/' + data.processedKey;
            status.innerHTML = 'Processing complete: <a href="' + imgUrl + '" target="_blank">' + imgUrl + '</a>';
        } else {
            status.textContent = 'Processing failed: ' + data.error;
        }
    };
    checkStatus();
});
</script>
</body>
</html>

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.

<?php
require_once __DIR__ . '/../api/config.php';
require_once __DIR__ . '/../vendor/autoload.php';

use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;
use Aws\Sts\StsClient;
use Aws\S3\S3Client;

header('Content-Type: application/json');

$payload = json_decode(file_get_contents('php://input'), true);
if (!$payload || !isset($payload['key'], $payload['contentType'])) {
    http_response_code(400);
    echo json_encode(['error' => '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.

<?php
require_once __DIR__ . '/../api/config.php';
require_once __DIR__ . '/../vendor/autoload.php';

use Aws\DynamoDb\DynamoDbClient;

header('Content-Type: application/json');

$key = $_GET['key'] ?? '';
if (!$key) {
    http_response_code(400);
    echo json_encode(['error' => '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

  1. 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.
  2. IAM Roles: The Lambda should run under an IAM role that only has the permissions it needs. Use least privilege.
  3. 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.
  4. 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.
  5. Rekognition Moderation

    ❓ Frequently Asked Questions

    📺 Recommended Video

    Watch this video for a practical overview of the topic covered in this article.

    ✍️ About the Author

    Vijay Vinoth — Lead Programmer Analyst with expertise in PHP, Perl, Python, and Shell scripting. Passionate about AI, automation, and building scalable systems. Writing to share practical insights from real-world engineering experience.

    Note: This technical analysis reflects my independent understanding as a Lead Programmer Analyst as of August 2026.
    As AI ecosystems like Claude 4.6 Opus evolve, actual implementation may vary. Refer to official documentation for final specs.

By AI

To optimize for the 2026 AI frontier, all posts on this site are synthesized by AI models and peer-reviewed by the author for technical accuracy. Please cross-check all logic and code samples; synthetic outputs may require manual debugging

Leave a Reply

Your email address will not be published. Required fields are marked *