---
title: Upload an Image With PHP cURL
description: Upload an image from PHP with CURLFile, checked HTTP status values, validated JSON, and safe handle cleanup.
slug: upload-image-php
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: Use PHP CURLFile to post an image as the file form field, then validate the JSON response and public URL.
cluster: Languages
intent: how-to
sources:
  - title: PHP CURLFile class
    url: https://www.php.net/manual/en/class.curlfile.php
  - title: PHP curl_exec function
    url: https://www.php.net/manual/en/function.curl-exec.php
  - title: PHP json_decode function
    url: https://www.php.net/manual/en/function.json-decode.php
  - title: imgd.dev OpenAPI document
    url: https://imgd.dev/openapi.json
---

# Upload an Image With PHP cURL

Use `CURLFile` to send the local image as the `file` field at `https://imgd.dev/v1/upload`. Check the HTTP status separately from `curl_exec`, then validate the JSON. The complete script reports the API `fix` field and verifies the returned public URL.

imgd.dev charges a one-time **$1 per GB** of storage. The service has **no free tier**, so fund the account before the first upload. Every imgd.dev image is public. Never upload private, confidential, or access-controlled content.

## Requirements

Use PHP 8 or later with the cURL extension. The script accepts JPEG, PNG, GIF, WebP, and AVIF files up to 20 MB.

The PHP manual states that `curl_exec` does not fail for an HTTP error status. The script therefore reads `CURLINFO_RESPONSE_CODE` after every request.

## Complete PHP script

Save this file as `examples/workflows/php/upload-image.php`:

```php
<?php

declare(strict_types=1);

const UPLOAD_URL = 'https://imgd.dev/v1/upload';

function stopWithError(string $message): void
{
    fwrite(STDERR, $message . PHP_EOL);
    exit(1);
}

function imageMime(string $path): string
{
    $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));

    return match ($extension) {
        'jpg', 'jpeg' => 'image/jpeg',
        'png' => 'image/png',
        'gif' => 'image/gif',
        'webp' => 'image/webp',
        'avif' => 'image/avif',
        default => throw new InvalidArgumentException(
            'Use a jpeg, png, gif, webp, or avif file extension.'
        ),
    };
}

function uploadImage(string $path, string $key): array
{
    $handle = curl_init(UPLOAD_URL);
    if ($handle === false) {
        throw new RuntimeException('Could not create the upload cURL handle.');
    }

    try {
        $file = new CURLFile($path, imageMime($path), basename($path));
        $configured = curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => ['file' => $file],
            CURLOPT_HTTPHEADER => [
                'Accept: application/json',
                'Authorization: Bearer ' . $key,
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT => 60,
        ]);
        if ($configured === false) {
            throw new RuntimeException('Could not configure the upload cURL handle.');
        }

        $body = curl_exec($handle);
        if ($body === false) {
            throw new RuntimeException('Upload transport error: ' . curl_error($handle));
        }

        return [
            'status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
            'content_type' => (string) curl_getinfo($handle, CURLINFO_CONTENT_TYPE),
            'body' => $body,
        ];
    } finally {
        curl_close($handle);
    }
}

function verifyPublicUrl(string $url): array
{
    $handle = curl_init($url);
    if ($handle === false) {
        throw new RuntimeException('Could not create the verification cURL handle.');
    }

    try {
        $configured = curl_setopt_array($handle, [
            CURLOPT_HTTPHEADER => ['Accept: image/*'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT => 60,
        ]);
        if ($configured === false) {
            throw new RuntimeException('Could not configure the verification cURL handle.');
        }

        $body = curl_exec($handle);
        if ($body === false) {
            throw new RuntimeException('Verification transport error: ' . curl_error($handle));
        }

        return [
            'status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
            'content_type' => (string) curl_getinfo($handle, CURLINFO_CONTENT_TYPE),
        ];
    } finally {
        curl_close($handle);
    }
}

try {
    if (!extension_loaded('curl')) {
        throw new RuntimeException('Enable the PHP cURL extension.');
    }

    $key = getenv('IMGD_KEY');
    if (!is_string($key) || trim($key) === '') {
        throw new RuntimeException('Set the IMGD_KEY environment variable.');
    }

    $path = $argv[1] ?? null;
    if (!is_string($path) || $path === '') {
        throw new InvalidArgumentException(
            'Usage: php upload-image.php /absolute/path/to/image.png'
        );
    }
    if (!is_file($path) || !is_readable($path)) {
        throw new InvalidArgumentException('The image path must be a readable file.');
    }

    $result = uploadImage($path, $key);
    $payload = json_decode($result['body'], true, 512, JSON_THROW_ON_ERROR);
    if (!is_array($payload)) {
        throw new RuntimeException('The API response must be a JSON object.');
    }

    if ($result['status'] < 200 || $result['status'] >= 300) {
        $error = is_string($payload['error'] ?? null) ? $payload['error'] : 'unknown_error';
        $fix = is_string($payload['fix'] ?? null) ? $payload['fix'] : 'Read the response body.';
        throw new RuntimeException(sprintf(
            'Upload failed with HTTP %d and %s: %s',
            $result['status'],
            $error,
            $fix
        ));
    }

    $url = $payload['url'] ?? null;
    $hash = $payload['hash'] ?? null;
    if (!is_string($url) || filter_var($url, FILTER_VALIDATE_URL) === false) {
        throw new RuntimeException('The success response has no valid public URL.');
    }
    if (!is_string($hash) || preg_match('/^[0-9a-f]{64}$/', $hash) !== 1) {
        throw new RuntimeException('The success response has no valid image hash.');
    }

    $verification = verifyPublicUrl($url);
    if (
        $verification['status'] < 200
        || $verification['status'] >= 300
        || !str_starts_with($verification['content_type'], 'image/')
    ) {
        throw new RuntimeException(sprintf(
            'Public URL verification failed with HTTP %d and Content-Type %s.',
            $verification['status'],
            $verification['content_type']
        ));
    }

    fwrite(STDERR, sprintf(
        "Verified %s with HTTP %d and Content-Type %s.%s",
        $url,
        $verification['status'],
        $verification['content_type'],
        PHP_EOL
    ));
    echo json_encode(
        $payload,
        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
    ) . PHP_EOL;
} catch (Throwable $error) {
    stopWithError($error->getMessage());
}
```

Both cURL handles close in `finally` blocks. This cleanup occurs after success, an HTTP error, a JSON error, or a transport error.

## Run the script

Store `IMGD_KEY` outside the repository. Do not put it in the script, the image URL, or a log.

```bash
cd examples/workflows/php
test -n "$IMGD_KEY"
php upload-image.php /absolute/path/to/image.png | tee upload.json
```

A new image returns HTTP `202` with this complete JSON shape:

```json
{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "processing",
  "mime": "image/png",
  "bytes": 18452,
  "width": 1200,
  "height": 800,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes"
}
```

An identical file can return HTTP `200`. Its JSON also contains `"deduplicated": true`.

The URL works immediately. During moderation, it can serve a blurred image. The URL serves the original image after a `live` result.

## Verify the result

The script fetches the URL and requires an `image/*` response. Also inspect the owner metadata:

```bash
HASH="$(jq -r '.hash' upload.json)"
curl --fail --silent --show-error \
  -H "Authorization: Bearer $IMGD_KEY" \
  "https://imgd.dev/v1/images/$HASH" | jq .
```

A completed response can contain this JSON:

```json
{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "live",
  "mime": "image/png",
  "bytes": 18452,
  "width": 1200,
  "height": 800,
  "alt_text": "A PHP terminal session that reports a verified image URL.",
  "category": "safe",
  "nsfw": false,
  "violence": false,
  "filename": "image.png",
  "unpublish_at": null,
  "published": true,
  "created_at": 1787184000
}
```

A `review` result is terminal and stays blurred. A `blocked` result removes the bytes. Do not retry the same blocked file.

## Recover by HTTP status

The API sends an `error` and a direct `fix` value for each 4xx response.

| Status | API error | Required action |
| --- | --- | --- |
| `401` | `missing_api_key` or `invalid_api_key` | Set the correct `IMGD_KEY`. Do not retry an unchanged invalid key. |
| `402` | `payment_required` | Buy storage and confirm the account state, then retry. |
| `402` | `quota_exceeded` | Buy the suggested storage or delete unused images, then retry. |
| `413` | `file_too_large` | Reduce the file below 20 MB, then retry. |
| `415` | `unsupported_media_type` | Use JPEG, PNG, GIF, WebP, or AVIF with a matching type. |
| `429` | `rate_limited` | Wait for the next minute, then retry. |

A representative `415` response is:

```json
{
  "error": "unsupported_media_type",
  "fix": "send jpeg, png, gif, webp, or avif and set the matching Content-Type",
  "accepted": ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif"]
}
```

Do not use only `curl_exec($handle) === false` as the API error check. That check detects transport errors, not normal HTTP 4xx responses.

## Security notes

- Keep `IMGD_KEY` in an environment variable or a secret manager.
- Keep TLS peer and host verification enabled.
- Do not enable verbose cURL output in a job that has the key.
- Do not put the key in a URL, repository, exception report, or log.
- Treat the returned URL as public and shareable.
- Delete stored bytes when the public image is no longer suitable.

## When imgd.dev fits

imgd.dev fits PHP jobs that need a simple public image URL without a storage subscription. The content hash also gives identical bytes the same URL.

It does not fit private images, gated downloads, files above 20 MB, video, or a free trial. Select another host for those requirements.

## Next guides

- [Upload an image with Rust](/blog/upload-image-rust/) for an async typed client.
- [Upload an image with Ruby](/blog/upload-image-ruby/) for a standard-library client.
- [Add the result to Markdown or a README](/blog/host-images-markdown-readme/).

For an agent, follow the [integration procedure](/integrate.md). For human setup, open [the start page](/#start). Use the [service evaluation](/evaluate.md) before you select a host.

## Sources

- [PHP CURLFile class](https://www.php.net/manual/en/class.curlfile.php)
- [PHP curl_exec function](https://www.php.net/manual/en/function.curl-exec.php)
- [PHP json_decode function](https://www.php.net/manual/en/function.json-decode.php)
- [imgd.dev OpenAPI document](https://imgd.dev/openapi.json)
