# Instagram API PHP Tutorial: Guzzle, cURL, and Laravel

PHP code for the Instagram API with Guzzle, cURL, and Laravel's HTTP client: read media, publish a container, refresh the token, and why the old SDK is out.

Canonical page: https://www.tryadeli.com/blog/instagram-api-php
Last updated: 2026-10-19

By Mika Reyes, Co-founder, Adeli. Published 2026-10-19.

## The short answer

To use the Instagram API from PHP, send HTTP requests with Guzzle, cURL, or Laravel's HTTP client to graph.instagram.com with an Instagram User access token. Read the profile from /me and posts from /{ig-id}/media, publish by creating a container, polling it, and calling media_publish, and refresh the token before day 60. The old Facebook PHP SDK is archived. Adeli does the publish in one POST.

You can use the Instagram API from PHP with any HTTP client: **send an Instagram User access token to `graph.instagram.com`, read the profile and media, and publish with a container plus `media_publish`**. Below is PHP code for each call with Guzzle, a plain cURL version, and a Laravel variant, written against Meta's documented endpoints. Then a token refresh and the same publish through Adeli.

This tutorial assumes you already have a Meta app and a token. If you don't, start with our [Instagram API](https://www.tryadeli.com/blog/instagram-api-guide) guide. Writing Python instead? The same flow is in our [Instagram API Python tutorial](https://www.tryadeli.com/blog/instagram-api-python).

## Is there an official Instagram PHP SDK?

**No, and the old Facebook SDK for PHP is archived.** The `facebook/graph-sdk` package lives in the [facebookarchive/php-graph-sdk](https://github.com/facebookarchive/php-graph-sdk) repository, which GitHub shows as **archived on January 13, 2022** and read-only. Its last release is 5.7.0, and its README says v5 only works with Guzzle 5 out of the box.

| Option | Status | Use it for Instagram? |
|---|---|---|
| `facebook/graph-sdk` | Archived 2022, read-only | No. Unmaintained, old Guzzle |
| `facebook/php-business-sdk` | Maintained by Meta | Built for the [Marketing API](https://github.com/facebook/facebook-php-business-sdk), not publishing |
| Guzzle (`guzzlehttp/guzzle`) | Maintained, v7 | Yes. Used below |
| PHP's cURL extension | Built in | Yes, for small scripts |
| Laravel HTTP client | Built in, wraps Guzzle | Yes, in Laravel apps |

Searching "instagram api php github" also turns up wrappers for the old Instagram API and for Instagram's private API. **Skip anything built on the Basic Display API**, which Meta [shut down on December 4, 2024](https://developers.facebook.com/blog/post/2024/09/04/update-on-instagram-basic-display-api/), and skip private-API libraries, which log in as the user and put the account at risk.

## Should I use Instagram Login or Facebook Login?

**Use Instagram Login for most new PHP projects.** Meta runs two versions of the Instagram API. The calls below are the same on both apart from the host and the token.

| | Instagram API with Instagram Login | Instagram API with Facebook Login |
|---|---|---|
| **Host** | `graph.instagram.com` | `graph.facebook.com` |
| **Token** | Instagram User access token | Facebook Page access token for publishing |
| **Facebook Page needed** | No | Yes |
| **Read permission** | `instagram_business_basic` | `instagram_basic`, `pages_read_engagement` |
| **Publish permission** | `instagram_business_content_publish` | `instagram_content_publish` |

Sources: Meta's [content publishing guide](https://developers.facebook.com/docs/instagram-platform/content-publishing) and [IG Media reference](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-media). To switch the code to Facebook Login, change the base URI to `https://graph.facebook.com/v26.0/` and use a Page token. Our [permissions guide](https://www.tryadeli.com/blog/instagram-api-permissions) explains which scope each call needs.

## What do you need before you start?

- **PHP 8 and Guzzle 7.** Install Guzzle with `composer require guzzlehttp/guzzle`.
- **A professional Instagram account.** Business or Creator. Personal accounts can't be read or published to.
- **An Instagram User access token.** For a test on your own account, click **Generate token** on the Instagram API setup page in the App Dashboard. Long-lived tokens last **60 days**.
- **A public JPEG URL** for the publish step. Meta downloads the image from it, and **JPEG is the only image format** the container accepts.

Keep the token out of your source. The examples read it from an environment variable and set it once on the client:

```php
<?php
require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use Psr\Http\Message\ResponseInterface;

$ig = new Client([
    // Keep the trailing slash, and use relative paths without a leading slash,
    // or Guzzle drops the version segment.
    'base_uri' => 'https://graph.instagram.com/v26.0/',
    'headers'  => ['Authorization' => 'Bearer ' . getenv('IG_ACCESS_TOKEN')],
    'timeout'  => 30,
]);

function body(ResponseInterface $res): array
{
    return json_decode((string) $res->getBody(), true, 512, JSON_THROW_ON_ERROR);
}
```

## Step 1: Read the Instagram profile

**Call `me` with the fields you want.** Meta's [getting-started guide](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/get-started) uses `user_id` and `username`, and lists `account_type`, `followers_count`, `follows_count`, `media_count`, `name`, and `profile_picture_url` as other fields.

```php
$res = $ig->get('me', [
    'query' => ['fields' => 'user_id,username,account_type,followers_count,media_count'],
]);
$body = body($res);
// Meta's sample response wraps the user in a "data" array
$profile = $body['data'][0] ?? $body;
$igId = $profile['user_id'];
echo $profile['username'], ' ', $igId, PHP_EOL;
```

**`user_id` is the IG ID** that every other Instagram endpoint takes. Store it next to the token.

## Step 2: List the account's media

**Call the `{IG_ID}/media` edge.** It returns media in `data` and paging cursors in `paging`:

```php
$page = body($ig->get("{$igId}/media", [
    'query' => ['fields' => 'id,media_type,media_url,permalink,timestamp'],
]));

foreach ($page['data'] as $media) {
    echo $media['timestamp'], ' ', $media['media_type'], ' ', $media['permalink'], PHP_EOL;
}
```

Some fields don't always come back, per Meta's [IG Media reference](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-media):

- **`media_url` can be missing.** Meta omits it when the media contains copyrighted material. Use `$media['media_url'] ?? null`.
- **`thumbnail_url` is video-only.** Image posts don't return it.
- **`like_count` can be missing.** It's omitted when the owner hides like counts.

For the next page, pass `$page['paging']['cursors']['after']` as the `after` query parameter.

## Step 3: Create a media container

**Publishing starts with a container: `POST {IG_ID}/media` with a public `image_url`.** Meta's [content publishing guide](https://developers.facebook.com/docs/instagram-platform/content-publishing) sends a JSON body with a bearer token, which Guzzle's `json` option does for you:

```php
$containerId = body($ig->post("{$igId}/media", [
    'json' => [
        'image_url' => 'https://www.example.com/images/photo.jpg',
        'caption'   => 'Posted from PHP',
    ],
]))['id'];
```

The same request with **PHP's cURL extension**, for a script without Composer:

```php
$ch = curl_init("https://graph.instagram.com/v26.0/{$igId}/media");
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('IG_ACCESS_TOKEN'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'image_url' => 'https://www.example.com/images/photo.jpg',
        'caption'   => 'Posted from PHP',
    ]),
]);
$data = json_decode((string) curl_exec($ch), true) ?? [];
$containerId = $data['id'] ?? throw new RuntimeException(json_encode($data['error'] ?? $data));
```

The image has to be **a JPEG on a publicly reachable server** when Meta fetches it. A signed URL that expires in seconds, or a file behind auth, fails. Our guide to [posting to Instagram via API](https://www.tryadeli.com/blog/post-to-instagram-via-api) lists the size and aspect-ratio rules.

## Step 4: Wait for the container to finish

**Poll `status_code` until it reads `FINISHED`.** Meta recommends checking **once per minute, for no more than 5 minutes**:

```php
for ($i = 0; $i < 5; $i++) {
    $status = body($ig->get($containerId, [
        'query' => ['fields' => 'status_code'],
    ]))['status_code'];

    if ($status === 'FINISHED') {
        break;
    }
    if (in_array($status, ['ERROR', 'EXPIRED'], true)) {
        throw new RuntimeException("Container {$containerId} is {$status}");
    }
    sleep(60);
}

if ($status !== 'FINISHED') {
    throw new RuntimeException('Container did not finish in 5 minutes');
}
```

Images usually finish on the first check. Video takes longer. In a web request, run this in a queued job instead of blocking the response for minutes.

## Step 5: Publish with media_publish

**POST the container ID as `creation_id`.** The response holds the ID of the live post:

```php
$postId = body($ig->post("{$igId}/media_publish", [
    'json' => ['creation_id' => $containerId],
]))['id'];
echo "Published: {$postId}", PHP_EOL;
```

**Each account can publish 100 posts in a rolling 24 hours** through the API, per Meta's publishing guide, and a carousel counts as one. Our [rate limit guide](https://www.tryadeli.com/blog/instagram-api-rate-limit) covers the call limits on top of that.

## How do you call the Instagram API from Laravel?

**Use Laravel's HTTP client, which wraps Guzzle.** A macro in `AppServiceProvider::boot()` sets the base URL and token once:

```php
use Illuminate\Support\Facades\Http;

Http::macro('instagram', function (string $token) {
    return Http::baseUrl('https://graph.instagram.com/v26.0')
        ->withToken($token)
        ->acceptJson()
        ->timeout(30);
});
```

Then each step is one line. `throw()` raises an exception on a 4xx or 5xx, and `json('id')` reads a key from the body:

```php
$ig = fn () => Http::instagram($account->instagram_token);

$profile = $ig()->get('me', ['fields' => 'user_id,username'])->throw()->json();
$igId = ($profile['data'][0] ?? $profile)['user_id'];

$containerId = $ig()->post("{$igId}/media", [
    'image_url' => $imageUrl,
    'caption'   => $caption,
])->throw()->json('id');

$postId = $ig()->post("{$igId}/media_publish", [
    'creation_id' => $containerId,
])->throw()->json('id');
```

Laravel sends `post()` data as JSON by default. Two habits worth adding:

- **Encrypt the token at rest.** Laravel's `encrypted` model cast stores the token encrypted in the database.
- **Run Steps 3 to 5 in a queued job.** The polling loop can take minutes, and the queue retries on failure.

## How do you refresh the token in PHP?

**Call `refresh_access_token` with `grant_type=ig_refresh_token` before day 60.** Meta's [refresh reference](https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token) only accepts a long-lived token that is **at least 24 hours old and not expired**, and the new token lasts 60 days from the refresh:

```php
function refreshToken(string $longLivedToken): array
{
    $res = (new Client())->get('https://graph.instagram.com/refresh_access_token', [
        'query' => [
            'grant_type'   => 'ig_refresh_token',
            'access_token' => $longLivedToken,
        ],
    ]);

    return body($res); // access_token, token_type, expires_in (seconds)
}
```

Save the new `access_token` and compute its expiry from `expires_in`. Run the refresh on a schedule, around day 45, so a failed run has time to retry. In Laravel, that's a daily scheduled command that refreshes every token older than 45 days. If you start from the 1-hour token the login flow returns, exchange it first with `grant_type=ig_exchange_token`. Our [access token guide](https://www.tryadeli.com/blog/instagram-api-access-token) has that call and the error 190 fixes.

## How do you handle Instagram API errors in PHP?

**Catch Guzzle's `ClientException` and read the `error` object in the body.** Guzzle throws on 4xx responses by default, and Graph API errors carry a `code`, an `error_subcode`, and a `message`:

```php
use GuzzleHttp\Exception\ClientException;

try {
    $ig->post("{$igId}/media_publish", ['json' => ['creation_id' => $containerId]]);
} catch (ClientException $e) {
    $err = body($e->getResponse())['error'] ?? [];
    error_log(sprintf('%s/%s: %s',
        $err['code'] ?? '?', $err['error_subcode'] ?? '-', $err['message'] ?? $e->getMessage()));
    throw $e;
}
```

**Code 190 means the token is dead.** Send the user back through login. Publishing subcodes such as 2207052 (media couldn't be fetched) and 2207027 (not ready to publish) are listed in Meta's [error code reference](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes). In Laravel, `throw()` raises a `RequestException`, and `$e->response->json('error')` gives the same object.

## How do you publish the same image with Adeli in PHP?

**One POST to `https://app.tryadeli.com/api/v1/posts` replaces Steps 3 to 5 and the refresh job.** [Adeli](https://www.tryadeli.com/product/instagram) holds the Meta developer app, carried Meta's review, and **refreshes every connected account's tokens**. Your code sends **one API key** and the image as base64, and Adeli stages it where Instagram can fetch it:

```php
<?php
require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;

$adeli = new Client(['base_uri' => 'https://app.tryadeli.com/api/v1/']);

$res = $adeli->post('posts', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('ADELI_API_KEY')],
    'json'    => [
        'platform'  => 'instagram',
        'accountId' => '00000000-0000-0000-0000-000000000000',
        'caption'   => 'Posted from PHP',
        'image'     => [
            'contentType' => 'image/png',
            'base64'      => base64_encode(file_get_contents('photo.png')),
        ],
    ],
]);

echo $res->getStatusCode(), ' ', $res->getBody(), PHP_EOL;
```

In Laravel, the same call is `Http::withToken(config('services.adeli.key'))->post('https://app.tryadeli.com/api/v1/posts', [...])`. What changes compared with the direct calls:

- **No public hosting.** Images can be JPEG, PNG, or WebP up to 8 MiB each, and Adeli normalizes them to JPEG.
- **No polling.** Success returns `201` with the post's `permalink`.
- **Readable failures.** An expired connection returns `422 connection_expired`, and an Instagram rejection returns `422 provider_rejected` with Instagram's message.
- **Carousels in the same call.** Swap `image` for `images` with 2 to 10 entries.

Every field is in the [Publish to Instagram docs](https://www.tryadeli.com/docs/api/posts/publish-instagram). The same endpoint in [Adeli's posting API](https://www.tryadeli.com/social-media-posting-api) publishes to TikTok, YouTube, Facebook, X, and Bluesky, and **your first 3 connected accounts are free** on [Adeli's pricing](https://www.tryadeli.com/pricing).

## Frequently asked questions

### Is there an official Instagram API SDK for PHP?

No. Meta documents the Instagram API as plain HTTP requests, and its old Facebook SDK for PHP (facebook/graph-sdk) was archived on GitHub on January 13, 2022 and is read-only. Meta's maintained PHP library, facebook/php-business-sdk, targets the Marketing API. For posting and reading media, a plain HTTP client such as Guzzle is the usual choice.

### Is there a Laravel package for the Instagram API?

Community packages exist, but many were written for the Instagram Basic Display API, which Meta shut down on December 4, 2024. Laravel's built-in HTTP client covers every call the Instagram API needs, so most Laravel apps wrap it in a small service class or an Http macro instead of adding a package.

### Can I post to a personal Instagram account with PHP?

No. The Instagram API only works with professional accounts, meaning Business or Creator accounts. A personal account has to switch to a professional account in the Instagram app before any PHP code can publish to it.

### Can I upload a local image file to Instagram from PHP?

Not directly. Meta's container endpoint takes an image_url that Meta downloads from a public server, so a file on your server's disk has to be reachable at a public URL first. Adeli's publish endpoint accepts the image as base64 and stages it where Instagram can fetch it.

## Related Adeli pages

- [Instagram API for Developers](https://www.tryadeli.com/product/instagram.md): Publish, schedule, and read analytics for Instagram through one API. Adeli owns the Instagram developer app, token refresh, and Meta review. Free to start.
- [Social Media Posting & Scheduling API](https://www.tryadeli.com/social-media-posting-api.md): Adeli's social media posting and scheduling API publishes to Instagram, TikTok, YouTube, Facebook, X, and Bluesky in one request, now or at a set time. Free to start.
- [All posts on the Adeli blog](https://www.tryadeli.com/blog.md)

## About the author

Mika Reyes is the CEO behind Adeli and a tech and AI content creator. She was previously co-founder and CEO of Parallax, which was acquired by Phantom, and a product lead at LinkedIn. She is a Forbes 30 Under 30 honoree.
