Image captioning allows a computer to examine a picture and produce a written description of what it sees.
Instead of manually writing captions for every uploaded image, we can build a PHP application that sends the image to an AI vision model and receives a useful caption in return.
In this tutorial, we will build a complete AI-powered image captioning system using PHP, HTML, CSS, JavaScript and the OpenAI Responses API.
The finished application will allow a user to:
- Select a JPG, PNG or WebP image
- Preview the image before submitting it
- Choose a caption style
- Upload the image securely to PHP
- Send the image to an AI model
- Display the generated caption
- Copy the caption with one click
- Handle invalid files and API errors safely
The project uses one PHP file, so it is suitable for beginners and can run on XAMPP, Laragon, MAMP or a normal PHP web server.
Important: This project uses a paid API. API usage is separate from a ChatGPT subscription. Check the current model availability and pricing before deploying it publicly.
Quick Answer
The basic process is:
- The browser sends an image to PHP.
- PHP checks the file size and MIME type.
- PHP converts the image into a Base64 data URL.
- PHP sends the image and caption instructions to a vision-capable AI model.
- The model returns text describing the image.
- PHP safely displays the caption in the browser.
The central API request looks like this:
$payload = [
"model" => "gpt-5-nano",
"input" => [
[
"role" => "user",
"content" => [
[
"type" => "input_text",
"text" => $prompt
],
[
"type" => "input_image",
"image_url" => $imageDataUrl,
"detail" => "auto"
]
]
]
],
"max_output_tokens" => 300,
"store" => false
];
OpenAI’s current image-input guide confirms that the Responses API accepts images as fully qualified URLs, file IDs or Base64 data URLs. The model used in this tutorial accepts image input and returns text. See the official images and vision guide and GPT-5 nano model page.
What Is AI Image Captioning?
AI image captioning combines computer vision and natural-language generation.
Computer vision helps the model examine visual information such as:
- Objects
- Colours
- People
- Animals
- Buildings
- Text visible in the image
- Actions
- Relationships between objects
- The general setting
The language-generation part turns those observations into readable text.
For example, an uploaded picture might produce:
A brown dog runs across a grassy field while carrying a red ball.
The same image could receive different captions depending on the instruction:
| Caption type | Possible result |
|---|---|
| Short caption | A dog carrying a red ball in a field. |
| Social-media caption | Chasing sunshine, fresh air and one very important red ball. |
| Alt text | Brown dog running on grass with a red ball in its mouth. |
| Detailed description | A medium-sized brown dog runs from left to right across a green field while holding a red rubber ball. |
This is why the prompt matters. We are not only sending an image; we are also telling the model what kind of description to produce.
Where Can Image Captioning Be Used?
An image-captioning system can be used in:
- Blogging platforms
- Product catalogues
- Social-media tools
- Photo-management applications
- Content-management systems
- Accessibility workflows
- News and media websites
- Real-estate listings
- Travel websites
- Classroom projects
- Internal document systems
For example, a travel blogger could upload a photograph of Fushimi Inari Shrine and ask the application to suggest a short caption. An online shop could request a draft product description. A content editor could generate a first version of image alt text and then review it before publishing.
AI output should be treated as a draft. A model can overlook an object, misread visible text or describe something incorrectly. Human review remains important, especially for accessibility, journalism, medical images, identity-related claims and product information.
How the PHP Application Works
| Stage | What happens |
| 1. Select | The user chooses an image in the browser. |
| 2. Preview | JavaScript displays a local preview. |
| 3. Upload | The browser sends the form using multipart/form-data. |
| 4. Validate | PHP checks the upload error, size and actual MIME type. |
| 5. Encode | PHP converts the image bytes into a Base64 data URL. |
| 6. Request | PHP sends the prompt and image to the Responses API. |
| 7. Extract | PHP finds the returned output_text. |
| 8. Display | The caption is escaped and shown to the user. |
The API key stays on the server. It is never placed inside JavaScript or sent to the user’s browser.
Project Requirements
You will need:
- PHP 8.1 or newer
- The PHP cURL extension
- The PHP Fileinfo extension
- A web server or PHP’s built-in development server
- An OpenAI API key
- An API account with billing or available credits
- A code editor such as Visual Studio Code
Check your PHP version:
php -v
Check whether cURL and Fileinfo are enabled:
php -m
Look for:
curl
fileinfo
If you are using XAMPP or Laragon, these extensions are commonly included, although cURL may need to be enabled in php.ini.
Step 1: Create the Project Folder
Create a folder named:
php-image-captioner
Inside it, create:
index.php
The project begins with only one file:
php-image-captioner/
└── index.php
Keeping the first version in one file makes the request flow easier to understand. Later, the CSS, JavaScript and API code can be separated into their own files or classes.
Step 2: Create and Protect the API Key
Create an API key from your API account.
Do not write the key directly into index.php, commit it to Git or expose it in browser-side JavaScript.
Use an environment variable named:
OPENAI_API_KEY
Linux or macOS
For the current terminal session:
export OPENAI_API_KEY="your_api_key_here"
Then start the PHP server from the same terminal.
Windows PowerShell
For the current PowerShell session:
$env:OPENAI_API_KEY="your_api_key_here"
Apache
Production servers should inject the secret through their deployment system, hosting control panel, secret manager or server environment. Avoid placing secrets in a publicly accessible .env file.
PHP will read the value with:
$apiKey = getenv("OPENAI_API_KEY");
If an API key has ever appeared in a public repository or webpage, revoke it and create a new one. Removing the visible text from the latest commit is not enough because the secret may remain in Git history.
Step 3: Add the Complete PHP Application
Copy the following code into index.php:
<?php
declare(strict_types=1);
session_start();
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
$allowedMimeTypes = [
"image/jpeg",
"image/png",
"image/webp"
];
$captionStyles = [
"short" => [
"label" => "Short caption",
"prompt" => "Write one clear factual sentence describing this image. "
. "Keep it below 25 words. Do not start with 'This image shows'."
],
"social" => [
"label" => "Social-media caption",
"prompt" => "Write an engaging but truthful social-media caption for this image. "
. "Use no more than 35 words and at most two suitable emojis. "
. "Do not invent names, places or events that cannot be confirmed visually."
],
"alt" => [
"label" => "Accessible alt text",
"prompt" => "Write concise alt text for this image. Describe the most important "
. "visible content and function in no more than 125 characters. "
. "Do not begin with 'image of' or 'picture of'. "
. "Do not guess identity or sensitive personal attributes."
],
"detailed" => [
"label" => "Detailed description",
"prompt" => "Describe this image accurately in two or three sentences. "
. "Mention the main subjects, visible actions, setting and important details. "
. "Clearly express uncertainty instead of guessing."
]
];
$caption = null;
$error = null;
$selectedStyle = $_POST["caption_style"] ?? "short";
if (
!is_string($selectedStyle)
|| !isset($captionStyles[$selectedStyle])
) {
$selectedStyle = "short";
}
if (empty($_SESSION["csrf_token"])) {
$_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}
function extractOutputText(array $response): ?string
{
$parts = [];
foreach ($response["output"] ?? [] as $outputItem) {
foreach ($outputItem["content"] ?? [] as $contentItem) {
if (
($contentItem["type"] ?? "") === "output_text"
&& isset($contentItem["text"])
) {
$parts[] = trim((string) $contentItem["text"]);
}
}
}
$text = trim(implode("\n", array_filter($parts)));
return $text !== "" ? $text : null;
}
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$submittedToken = $_POST["csrf_token"] ?? "";
if (
!is_string($submittedToken)
|| !hash_equals($_SESSION["csrf_token"], $submittedToken)
) {
$error = "The form session has expired. Refresh the page and try again.";
} elseif (!isset($_FILES["image"])) {
$error = "Please choose an image.";
} elseif ($_FILES["image"]["error"] !== UPLOAD_ERR_OK) {
$error = "The image could not be uploaded. Please try another file.";
} elseif ($_FILES["image"]["size"] > MAX_IMAGE_SIZE) {
$error = "The image is too large. The maximum size is 5 MB.";
} else {
$temporaryPath = $_FILES["image"]["tmp_name"];
$fileInfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $fileInfo->file($temporaryPath);
if (!in_array($mimeType, $allowedMimeTypes, true)) {
$error = "Only JPG, PNG and WebP images are allowed.";
} else {
$imageBytes = file_get_contents($temporaryPath);
if ($imageBytes === false) {
$error = "PHP could not read the uploaded image.";
} else {
$apiKey = getenv("OPENAI_API_KEY");
if (!$apiKey) {
$error = "The server is missing its API configuration.";
} else {
$imageDataUrl = "data:"
. $mimeType
. ";base64,"
. base64_encode($imageBytes);
$payload = [
"model" => "gpt-5-nano",
"input" => [
[
"role" => "user",
"content" => [
[
"type" => "input_text",
"text" => $captionStyles[$selectedStyle]["prompt"]
],
[
"type" => "input_image",
"image_url" => $imageDataUrl,
"detail" => "auto"
]
]
]
],
"max_output_tokens" => 300,
"store" => false
];
$curl = curl_init("https://api.openai.com/v1/responses");
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . $apiKey,
"Content-Type: application/json"
],
CURLOPT_POSTFIELDS => json_encode(
$payload,
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
)
]);
$responseBody = curl_exec($curl);
$curlError = curl_error($curl);
$statusCode = (int) curl_getinfo(
$curl,
CURLINFO_HTTP_CODE
);
curl_close($curl);
if ($responseBody === false) {
$error = "The caption service could not be reached: "
. $curlError;
} else {
try {
$responseData = json_decode(
$responseBody,
true,
512,
JSON_THROW_ON_ERROR
);
if ($statusCode < 200 || $statusCode >= 300) {
$apiMessage = $responseData["error"]["message"]
?? "The API rejected the request.";
$error = "Caption generation failed: "
. $apiMessage;
} else {
$caption = extractOutputText($responseData);
if ($caption === null) {
$error = "The API returned no caption. Please try again.";
}
}
} catch (JsonException $exception) {
$error = "The caption service returned an invalid response.";
}
}
}
}
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Image Caption Generator</title>
<style>
:root {
color-scheme: light;
--background: #f4f7fb;
--card: #ffffff;
--text: #172033;
--muted: #667085;
--primary: #5b4cf0;
--primary-dark: #4338ca;
--border: #dce2eb;
--success: #ecfdf3;
--success-border: #86efac;
--danger: #fff1f2;
--danger-border: #fda4af;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
padding: 40px 18px;
font-family: Arial, Helvetica, sans-serif;
color: var(--text);
background: linear-gradient(135deg, #eef2ff, var(--background));
}
.app {
width: min(760px, 100%);
margin: 0 auto;
padding: 32px;
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 22px;
background: var(--card);
box-shadow: 0 20px 60px rgba(23, 32, 51, 0.12);
}
h1 {
margin: 0 0 10px;
font-size: clamp(1.8rem, 5vw, 2.5rem);
}
.intro {
margin: 0 0 28px;
color: var(--muted);
line-height: 1.6;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 700;
}
input[type="file"],
select {
width: 100%;
margin-bottom: 20px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
font: inherit;
}
input[type="file"]::file-selector-button {
margin-right: 12px;
padding: 9px 14px;
border: 0;
border-radius: 8px;
color: #fff;
background: var(--primary);
cursor: pointer;
}
.preview {
display: none;
width: 100%;
max-height: 380px;
margin: 0 0 22px;
border-radius: 14px;
object-fit: contain;
background: #f8fafc;
}
.generate-button,
.copy-button {
border: 0;
border-radius: 10px;
color: #fff;
background: var(--primary);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.generate-button {
width: 100%;
padding: 14px 18px;
}
.generate-button:hover,
.copy-button:hover {
background: var(--primary-dark);
}
.generate-button:disabled {
cursor: wait;
opacity: 0.7;
}
.message,
.result {
margin-top: 24px;
padding: 18px;
border: 1px solid;
border-radius: 12px;
line-height: 1.6;
}
.message {
border-color: var(--danger-border);
background: var(--danger);
}
.result {
border-color: var(--success-border);
background: var(--success);
}
.result h2 {
margin: 0 0 8px;
font-size: 1.1rem;
}
.caption-text {
margin: 0 0 14px;
white-space: pre-wrap;
}
.copy-button {
padding: 9px 14px;
}
.help {
margin-top: -12px;
margin-bottom: 20px;
color: var(--muted);
font-size: 0.9rem;
}
@media (max-width: 600px) {
body {
padding: 18px 12px;
}
.app {
padding: 22px;
border-radius: 16px;
}
}
</style>
</head>
<body>
<main class="app">
<h1>AI Image Caption Generator</h1>
<p class="intro">
Upload an image and choose how you want the AI to describe it.
</p>
<form method="post" enctype="multipart/form-data" id="caption-form">
<input
type="hidden"
name="csrf_token"
value="<?php echo htmlspecialchars(
$_SESSION["csrf_token"],
ENT_QUOTES,
"UTF-8"
); ?>"
>
<label for="image">Choose an image</label>
<input
type="file"
name="image"
id="image"
accept="image/jpeg,image/png,image/webp"
required
>
<p class="help">JPG, PNG or WebP. Maximum file size: 5 MB.</p>
<img id="preview" class="preview" alt="Selected image preview">
<label for="caption_style">Caption style</label>
<select name="caption_style" id="caption_style">
<?php foreach ($captionStyles as $value => $style): ?>
<option
value="<?php echo htmlspecialchars($value); ?>"
<?php echo $selectedStyle === $value ? "selected" : ""; ?>
>
<?php echo htmlspecialchars($style["label"]); ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="generate-button" id="generate-button">
Generate caption
</button>
</form>
<?php if ($error !== null): ?>
<div class="message" role="alert">
<?php echo htmlspecialchars($error, ENT_QUOTES, "UTF-8"); ?>
</div>
<?php endif; ?>
<?php if ($caption !== null): ?>
<section class="result" aria-live="polite">
<h2>Generated caption</h2>
<p class="caption-text" id="caption-text"><?php
echo htmlspecialchars($caption, ENT_QUOTES, "UTF-8");
?></p>
<button type="button" class="copy-button" id="copy-button">
Copy caption
</button>
</section>
<?php endif; ?>
</main>
<script>
const imageInput = document.getElementById("image");
const preview = document.getElementById("preview");
const form = document.getElementById("caption-form");
const generateButton = document.getElementById("generate-button");
const copyButton = document.getElementById("copy-button");
imageInput.addEventListener("change", () => {
const file = imageInput.files[0];
if (!file) {
preview.removeAttribute("src");
preview.style.display = "none";
return;
}
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
});
form.addEventListener("submit", () => {
generateButton.disabled = true;
generateButton.textContent = "Generating caption...";
});
if (copyButton) {
copyButton.addEventListener("click", async () => {
const captionText = document
.getElementById("caption-text")
.innerText;
try {
await navigator.clipboard.writeText(captionText);
copyButton.textContent = "Copied!";
setTimeout(() => {
copyButton.textContent = "Copy caption";
}, 1500);
} catch (error) {
copyButton.textContent = "Copy failed";
}
});
}
</script>
</body>
</html>
Step 4: Run the Project
Open a terminal inside the project folder.
Make sure the OPENAI_API_KEY environment variable is available, then run:
php -S localhost:8000
Open this address in your browser:
http://localhost:8000
Select an image, choose a caption style and click Generate caption.
The first request may take several seconds because the image must be uploaded, encoded, processed and returned.
Understanding the Important PHP Code
The full example is long because it includes validation, error handling, styling and JavaScript. The essential PHP sections are easier to understand separately.
1. Start a Session
session_start();
The session stores the CSRF token used to confirm that the submitted form originated from the application.
2. Limit the File Size
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
This creates a five-megabyte application limit.
PHP and the web server can have their own upload limits. If PHP rejects a large upload before the script runs, inspect these php.ini settings:
upload_max_filesize = 6M
post_max_size = 7M
post_max_size should be slightly larger than upload_max_filesize because the full form request contains more than the image bytes.
Restart the web server after modifying php.ini.
3. Check the Actual MIME Type
$fileInfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $fileInfo->file($temporaryPath);
Do not rely only on:
$_FILES["image"]["type"]
That value comes from the browser and can be misleading. Fileinfo examines the uploaded file on the server.
Checking the filename extension alone is also insufficient. A dangerous or unrelated file can be renamed to end with .jpg.
4. Convert the Image to Base64
$imageDataUrl = "data:"
. $mimeType
. ";base64,"
. base64_encode($imageBytes);
This creates a data URL similar to:
data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...
Base64 makes it possible to include the image inside the JSON request. It increases the data size, so the tutorial limits uploads to 5 MB.
For larger production workflows, uploading once and referencing a file ID can be more efficient than repeatedly embedding the same image.
5. Send Text and Image Input Together
"content" => [
[
"type" => "input_text",
"text" => $captionStyles[$selectedStyle]["prompt"]
],
[
"type" => "input_image",
"image_url" => $imageDataUrl,
"detail" => "auto"
]
]
The model receives two related inputs:
- The written instruction
- The image to examine
The detail setting controls how the image is processed. auto lets the API select an appropriate level. A low-detail setting may reduce input usage for simple images, while high detail may be more suitable when small objects or text matter. Confirm supported values and current image-token behaviour in the official image-input documentation.
6. Keep the Key on the Server
"Authorization: Bearer " . $apiKey
The authorization header is created by PHP. The browser never sees the API key.
Never make a direct API request from public frontend JavaScript using a permanent secret key. Anyone who can inspect the page or network request could steal it and use your account.
7. Decode the Response
$responseData = json_decode(
$responseBody,
true,
512,
JSON_THROW_ON_ERROR
);
The JSON_THROW_ON_ERROR option causes PHP to throw a JsonException if the response is invalid JSON. The application catches the exception and shows a controlled error.
8. Extract output_text
The raw Responses API result contains an output array. Each output item can contain one or more content items.
The helper function searches them:
function extractOutputText(array $response): ?string
{
$parts = [];
foreach ($response["output"] ?? [] as $outputItem) {
foreach ($outputItem["content"] ?? [] as $contentItem) {
if (
($contentItem["type"] ?? "") === "output_text"
&& isset($contentItem["text"])
) {
$parts[] = trim((string) $contentItem["text"]);
}
}
}
$text = trim(implode("\n", array_filter($parts)));
return $text !== "" ? $text : null;
}
This is safer than assuming the caption is always at one fixed numeric position.
9. Escape the Caption Before Displaying It
echo htmlspecialchars(
$caption,
ENT_QUOTES,
"UTF-8"
);
AI output should be treated as untrusted text. htmlspecialchars() prevents returned HTML-like content from becoming executable webpage markup.
Why Use Different Caption Styles?
One description does not suit every job.
Short Caption
Useful beneath photographs in articles or galleries.
Prompt goal:
Write one clear factual sentence below 25 words.
Social-Media Caption
Allows slightly more personality while still instructing the model not to invent unsupported facts.
The model should not claim that a picture was taken in Tokyo merely because the street looks Japanese. It should not invent an event name, person’s identity or exact date.
Accessible Alt Text
Alt text communicates an image’s important content or function to people who cannot see it.
Good alt text depends on context. The same image may need different alt text on different pages. A decorative image may need empty alt text instead of an AI description.
Therefore, AI can suggest alt text, but the website author should decide whether the result is appropriate.
Detailed Description
Useful when the user needs more context than a short caption provides. It can mention the setting, positions, visible actions and important objects.
Choosing a Model
This tutorial uses:
"model" => "gpt-5-nano"
According to the current official model page, GPT-5 nano accepts text and image input and produces text output. It is positioned as the fastest, cheapest GPT-5 model, making it a practical starting point for a small captioning demo.
If its captions are not accurate enough for your images, replace the model ID with another vision-capable model available to your API project. A stronger model can improve difficult scene understanding, small-text recognition and instruction following, but it will usually cost more and may respond more slowly.
Model names, availability and pricing can change. Check the official OpenAI model guide before launching a production system.
Do not silently change models in a live application. Test the new model using a fixed set of representative images and compare:
- Factual accuracy
- Missing important details
- Invented details
- Caption length
- Latency
- Cost per request
- Performance on text-heavy images
- Performance on people, products and unusual scenes
Improving Caption Accuracy
Give a Specific Instruction
Weak prompt:
Describe this.
Better prompt:
Write one factual caption below 25 words. Mention the main subject and visible action. Do not guess the place, identity or event.
Specific requirements help the model understand what the application needs.
Tell the Model What Not to Guess
Image models can make plausible but unsupported assumptions.
Useful restrictions include:
- Do not identify unknown people.
- Do not infer ethnicity, religion, health or other sensitive traits.
- Do not invent a location.
- Do not assume the occasion.
- Do not claim uncertain text is readable.
- Express uncertainty when an object is unclear.
Preserve Enough Image Quality
Very small, blurred or heavily compressed images are difficult to understand. If important text occupies only a tiny portion of the image, crop the relevant area or use a suitable detail setting.
Test with Real Images
Do not test only with clear stock photography. Include examples that match the intended application:
- Dark images
- Crowded scenes
- Screenshots
- Products on similar backgrounds
- Images containing text
- Portrait and landscape orientations
- Mobile photographs
- Illustrations
- Partly obstructed subjects
Security Considerations
A demonstration running on a private computer is different from a public upload service. Before allowing unknown visitors to use the application, strengthen it.
1. Add Rate Limiting
Without rate limiting, one visitor or bot could send many requests and consume the API budget.
Possible limits include:
- Requests per IP address
- Requests per signed-in account
- Daily account allowance
- Maximum concurrent requests
- Monthly spending alerts
- A hard application budget
A session-based counter alone is not strong protection because users can start new sessions.
2. Require Authentication
If the tool is intended for staff or registered users, require login before permitting API requests.
3. Validate on the Server
The HTML accept attribute improves the file picker, but it is not a security control. An attacker can send a custom HTTP request.
Always enforce the file type and size in PHP.
4. Do Not Trust Original Filenames
The tutorial does not save the file permanently. If you add storage, generate a random server-side filename instead of using the uploaded name.
Store uploads outside the public web root where possible. If they must be public, configure the upload directory so PHP scripts cannot execute there.
5. Protect Secrets
- Keep the API key in a protected server environment.
- Never log the complete authorization header.
- Never return the key in an error response.
- Use different keys for development and production.
- Revoke exposed keys immediately.
- Restrict access to deployment secrets.
6. Do Not Display Raw AI Output as HTML
Use htmlspecialchars() for normal text output.
If a future version intentionally supports Markdown, process it with a well-maintained parser configured to block dangerous HTML and URLs.
7. Consider Image Privacy
Users may upload photographs containing faces, addresses, identity cards, vehicle plates, private documents or location information.
Explain clearly:
- What is uploaded
- Which external service processes it
- Whether the application stores it
- How long logs are kept
- Who can access the generated caption
Avoid keeping uploaded images unless the application actually needs them.
8. Moderate Public Content Where Necessary
A public tool may receive disturbing, illegal or abusive content. Establish an acceptable-use policy, reporting process and moderation controls suitable for the audience and jurisdiction.
9. Avoid Revealing Detailed Errors to Public Users
During development, the API’s error message helps with debugging. In production, log technical details privately and show the visitor a simpler message.
For example:
error_log("Caption API error: " . $apiMessage);
$error = "We could not generate a caption. Please try again later.";
Do not log Base64 image data or sensitive image content unnecessarily.
Common Errors and Solutions
Error: Call to undefined function curl_init()
The PHP cURL extension is unavailable or disabled.
Enable cURL in the active php.ini, then restart PHP or the web server.
Find the loaded configuration file with:
php --ini
Remember that command-line PHP and web-server PHP can load different configuration files.
Error: The Server Is Missing Its API Configuration
PHP cannot read OPENAI_API_KEY.
Confirm that the environment variable exists in the same environment that runs PHP. Setting it in one terminal does not automatically make it available to Apache, PHP-FPM or another terminal.
Error: HTTP 401
Common causes include:
- Missing API key
- Incorrect API key
- Revoked key
- Extra spaces or quote characters
- The key not reaching the PHP process
Do not print the full key while debugging. At most, check whether the environment variable exists.
Error: HTTP 429
This commonly indicates a rate-limit or quota problem.
Check the API account’s usage, billing state, project limits and rate limits. Add retry logic with backoff for temporary rate limits, but do not endlessly retry quota failures.
Error: The Uploaded File Is Rejected
Check:
- The actual MIME type
- The five-megabyte application limit
upload_max_filesizepost_max_size- Reverse-proxy request limits
- Web-server request limits
Error: PHP Times Out
The example gives cURL a 60-second timeout. A slow connection, large image or busy service can exceed a lower server timeout.
For a production application, consider a background job and status endpoint rather than keeping a web request open for a long time.
Error: No Caption Was Returned
Log the response structure in a protected development environment. The result may have been incomplete, refused or returned in an unexpected form.
Do not assume that every successful HTTP response contains usable caption text.
Error: Base64 Request Is Too Large
Base64 representation is larger than the original binary file. Reduce the upload limit, resize the image before sending it or use a file-upload workflow appropriate for the API.
Optional Improvement: Resize Images Before Sending
Large camera photographs can consume more bandwidth and memory than a caption requires.
A production application can resize images using GD or Imagick before encoding them. Preserve the aspect ratio and avoid reducing the image so much that important objects or text disappear.
For example, a general caption may not require a 6000-pixel-wide photograph. A maximum dimension of 1600 or 2000 pixels may be sufficient for many cases, but the correct value should be tested against your own image set.
Resizing can:
- Reduce upload time
- Reduce PHP memory usage
- Reduce request size
- Standardise image dimensions
- Potentially lower processing cost
Do not overwrite the user’s original unless that is an explicit product feature.
Optional Improvement: Return Structured JSON
A later version may need more than one caption field.
For example:
{
"short_caption": "A child flies a red kite on a beach.",
"alt_text": "Child flying a red kite beside the sea.",
"keywords": ["child", "kite", "beach", "sea"],
"needs_review": false
}
Structured output is useful when the result must be saved to a database or sent to another system.
The application should validate every returned field before using it. JSON produced by a model should not be trusted merely because it looks structured.
Optional Improvement: Save Caption History
You can add a database table containing:
| Column | Purpose |
id | Unique record ID |
user_id | Owner of the request |
image_path | Stored image location, if retained |
caption_style | Selected output type |
caption | Generated text |
model | Model used |
status | Pending, completed or failed |
created_at | Request time |
Do not save Base64 strings in a normal database column without a strong reason. Object storage or a protected filesystem is usually more suitable for images, while the database stores a reference.
Add retention and deletion rules instead of keeping every uploaded image forever.
Optional Improvement: Use a Queue
The single-page version waits for the API before returning the webpage.
That is acceptable for a small tutorial. A busy production application can instead:
- Validate the upload.
- Save a pending job.
- Return a job ID.
- Process the image in a worker.
- Save the caption.
- Let the browser check the job status.
A queue prevents slow AI requests from occupying all available web workers. It also makes controlled retries easier.
A queue is not automatically necessary for a private tool or low-traffic site. Add it when request volume, latency and reliability justify the extra complexity.
Testing Checklist
Test the application with:
- A normal JPG photograph
- A PNG screenshot
- A WebP image
- An image just below 5 MB
- An image above 5 MB
- A text file renamed to
.jpg - An empty form submission
- An invalid API key
- A missing API key
- A very dark image
- A crowded image
- An image containing small text
- An image containing a person
- A portrait-oriented phone image
- A caption containing quotation marks and symbols
Also confirm that:
- The API key does not appear in the HTML source.
- The API key does not appear in browser developer tools.
- Returned text is escaped.
- The submit button disables while waiting.
- The mobile layout remains usable.
- Failed requests do not expose stack traces or server paths in production.
Frequently Asked Questions
Does this project train an image-captioning model?
No. It uses an existing multimodal model through an API. Training a vision-and-language model from the beginning requires a large labelled dataset, substantial computing power and considerably more machine-learning knowledge.
Can PHP understand images by itself?
PHP can upload, validate, resize and store image files, but the caption is generated by the external AI model. PHP manages the web application and API communication.
Do I need Composer?
No. This version uses PHP’s built-in cURL extension, so it does not require a Composer package.
A larger project may use an HTTP client such as Guzzle or a maintained SDK to organise requests more cleanly.
Is the OpenAI API included with ChatGPT Plus?
No. ChatGPT subscriptions and API usage are billed separately. Configure API billing and limits for the API project used by the application.
Why use the Responses API?
It accepts multimodal input, including text and images, in a single request and is the current API pattern demonstrated in OpenAI’s image-input documentation.
Why is the image converted to Base64?
The image exists temporarily on the PHP server and may not have a public URL. A Base64 data URL allows PHP to send its bytes inside the JSON request.
Can I send an image URL instead?
Yes, if the URL is publicly reachable and meets the API’s requirements. Do not let users supply arbitrary URLs without considering server-side request forgery, private-network access and untrusted content.
Can I use this system to generate alt text?
It can generate a draft, but good alt text depends on the purpose and context of the image. A human editor should review it. Decorative images may require empty alt text rather than a description.
Can the AI identify a person in a photograph?
Do not build the captioning workflow around guessing or confirming identity. The tutorial’s prompts tell the model not to invent names or sensitive personal attributes.
Will the caption always be correct?
No. Vision models can miss objects, misunderstand relationships, invent details or misread text. Review important captions before publishing them.
Can I use the application on shared hosting?
Possibly. The host must support a suitable PHP version, cURL, Fileinfo, outbound HTTPS requests, environment-based secrets and request sizes large enough for the encoded image.
Some shared hosts block outbound API requests or provide no safe way to configure secrets. Check with the hosting provider.
Does the project save uploaded images?
No. It reads PHP’s temporary upload and sends it to the API. The application does not move it into permanent local storage.
How much does each caption cost?
Cost depends on the selected model, image size and detail, prompt size and output length. Model prices can change, so use the official pricing and model pages rather than hard-coding an old estimate into the application.
Should I use a queue?
For a simple private tool, probably not. For a public or busy site, a queue can improve reliability and prevent slow API calls from tying up all PHP workers.
Can I add several languages?
Yes. Add a language selector and include the chosen language in the prompt.
For example:
Write the caption in Bahasa Melayu.
Validate the selected language against a server-side allow-list instead of inserting unrestricted form text into important instructions.
Final Result
We have built a complete AI-powered image-captioning application with PHP.
The finished system:
- Accepts JPG, PNG and WebP uploads.
- Previews the selected image in the browser.
- Validates file size and MIME type on the server.
- Protects the form with a CSRF token.
- Keeps the API key out of frontend code.
- Converts the image into a Base64 data URL.
- Sends text and image input to a vision-capable model.
- Supports short, social, alt-text and detailed captions.
- Extracts text from the Responses API result.
- Escapes the caption before displaying it.
- Provides a copy button and clear error messages.
The single-file version is a strong learning project, but a public system should also add authentication, durable rate limiting, spending controls, private logging, privacy disclosures and monitoring.
Most importantly, remember that an AI caption is a suggestion rather than guaranteed truth. Review the result before using it for accessibility, publishing or business data.