Laravel Integration
Introduction
This guide shows how to use Feedframer from a Laravel 11 or 12 application with Laravel's HTTP client, cache and scheduler. It builds a small service class, a controller and a Blade view, then covers GraphQL, pagination, error handling and tests.
You need your API key. It's on your Dashboard, on the account's card, labelled "Feedframer API Token". Each connected Instagram account has its own key.
Installation & Setup
Step 1: Add API Key to Environment
Add your key to .env:
FEEDFRAMER_API_KEY=your_api_key_here
Step 2: Add a Services Entry
Add a feedframer entry to config/services.php:
// config/services.php
return [
// ... other services
'feedframer' => [
'api_key' => env('FEEDFRAMER_API_KEY'),
'base_url' => env('FEEDFRAMER_BASE_URL', 'https://feedframer.com'),
],
];
Every example below reads these two values with config('services.feedframer.api_key') and config('services.feedframer.base_url'). Don't call env() outside config files: it returns null once the config is cached.
Basic Usage
Simple HTTP Request
use Illuminate\Support\Facades\Http;
$data = Http::get(config('services.feedframer.base_url').'/api/v1/me', [
'api_key' => config('services.feedframer.api_key'),
])->throw()->json();
$posts = $data['posts'];
GET /api/v1/me returns the account's profile (username, followersCount and so on), a posts array and a pagination object. Without page[size], you get as many posts as your plan allows in one request: 6 on Free, 100 on Premium. See the REST API reference for every field.
throw() raises an Illuminate\Http\Client\RequestException for any error response. The service class below turns errors into something more useful.
Service Class Pattern
Create an Exception
Feedframer's error responses always look like this, whatever Accept header you send:
{
"errors": [
{
"status": "401",
"code": "INVALID_API_KEY",
"title": "Authentication Failed",
"detail": "The provided API key is invalid."
}
]
}
This exception keeps the code and the full errors list:
<?php
// app/Services/FeedframerException.php
namespace App\Services;
use Illuminate\Http\Client\Response;
use RuntimeException;
class FeedframerException extends RuntimeException
{
public function __construct(
public readonly int $status,
public readonly string $errorCode,
string $message,
public readonly array $errors = [],
) {
parent::__construct($message, $status);
}
public static function fromResponse(Response $response): self
{
$errors = $response->json('errors') ?? [];
return new self(
$response->status(),
$errors[0]['code'] ?? 'HTTP_'.$response->status(),
$errors[0]['detail'] ?? 'Unexpected response from Feedframer',
$errors,
);
}
}
Create the Service
<?php
// app/Services/FeedframerService.php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class FeedframerService
{
/** Profile plus posts. Accepts page[size], page[cursor], filter[...], sort, include and format. */
public function feed(array $params = []): array
{
return $this->get('/api/v1/me', $params);
}
/** The same as feed(), cached locally for $seconds. */
public function cachedFeed(array $params = [], int $seconds = 3600): array
{
return Cache::remember($this->cacheKey($params), $seconds, fn () => $this->feed($params));
}
/** Fetch fresh data and store it, e.g. from the scheduler. */
public function warmCache(array $params = [], int $seconds = 7200): void
{
Cache::put($this->cacheKey($params), $this->feed($params), $seconds);
}
/** The account this key belongs to, including its status. */
public function account(): array
{
return $this->get('/api/v1/accounts')['data'][0];
}
/** Every post the key can see, following cursors until hasMore is false. */
public function allPosts(array $params = []): array
{
$posts = [];
$cursor = null;
do {
$page = $this->feed($cursor ? $params + ['page[cursor]' => $cursor] : $params);
array_push($posts, ...$page['posts']);
$cursor = $page['pagination']['nextCursor'];
} while ($page['pagination']['hasMore'] && $cursor !== null);
return $posts;
}
/** Run a GraphQL query and return its "data". */
public function graphql(string $query, array $variables = []): array
{
$response = $this->send(fn (PendingRequest $http) => $http->post('/graphql', [
'query' => $query,
'variables' => (object) $variables,
]));
// GraphQL errors come back with HTTP 200 and an "errors" array.
if ($response->json('errors')) {
throw new FeedframerException(200, 'GRAPHQL_ERROR', $response->json('errors.0.message'), $response->json('errors'));
}
return $response->json('data');
}
protected function get(string $path, array $params = []): array
{
return $this->send(fn (PendingRequest $http) => $http->get($path, $params))->json();
}
protected function send(callable $request): \Illuminate\Http\Client\Response
{
$http = Http::baseUrl(config('services.feedframer.base_url'))
->withQueryParameters(['api_key' => config('services.feedframer.api_key')])
->timeout(10);
try {
$response = $request($http);
} catch (ConnectionException $e) {
throw new FeedframerException(0, 'CONNECTION_FAILED', $e->getMessage());
}
if ($response->failed()) {
throw FeedframerException::fromResponse($response);
}
return $response;
}
protected function cacheKey(array $params): string
{
ksort($params);
// Include the key so two accounts never share a cache entry.
return 'feedframer:'.hash('sha256', config('services.feedframer.api_key').'|'.http_build_query($params));
}
}
The API key goes in the query string on every request, GraphQL included. Feedframer doesn't read it from headers.
Use the Service in a Controller
<?php
// app/Http/Controllers/InstagramFeedController.php
namespace App\Http\Controllers;
use App\Services\FeedframerException;
use App\Services\FeedframerService;
use Illuminate\Http\Request;
class InstagramFeedController extends Controller
{
public function index(Request $request, FeedframerService $feedframer)
{
$params = ['page[size]' => 6];
if ($request->filled('cursor')) {
$params['page[cursor]'] = $request->query('cursor');
}
try {
$feed = $feedframer->cachedFeed($params);
} catch (FeedframerException $e) {
report($e);
return view('instagram.feed', [
'posts' => [],
'pagination' => null,
'error' => 'Unable to load Instagram posts',
]);
}
return view('instagram.feed', [
'posts' => $feed['posts'],
'pagination' => $feed['pagination'],
]);
}
}
// routes/web.php
use App\Http\Controllers\InstagramFeedController;
use Illuminate\Support\Facades\Route;
Route::get('/instagram', [InstagramFeedController::class, 'index'])->name('instagram');
page[size]=6 gives the same page size on both plans. Premium keys can raise it to 100.
Blade Template
Pick the image for each post like this:
sizesholds resized copies at 320, 768, 960 and 1200 pixels wide. Only Premium keys get it; on Free the key is missing.- For
VIDEOposts,mediaUrlis usually an.mp4file. UsethumbnailUrlin an<img>. - For
IMAGEandCAROUSEL_ALBUMposts, usemediaUrl.
{{-- resources/views/instagram/feed.blade.php --}}
<div class="instagram-feed">
@isset($error)
<div class="alert alert-error">{{ $error }}</div>
@endisset
<div class="grid grid-cols-3 gap-4">
@foreach ($posts as $post)
@php
$src = $post['sizes']['768']
?? ($post['mediaType'] === 'VIDEO' ? $post['thumbnailUrl'] : $post['mediaUrl']);
@endphp
@continue(! $src)
<div class="instagram-post">
<a href="{{ $post['permalink'] }}" target="_blank" rel="noopener">
<img src="{{ $src }}"
alt="{{ $post['altText'] ?? Str::limit($post['caption'] ?? '', 100) }}"
loading="lazy">
</a>
@if ($post['caption'])
<p class="post-caption">{{ Str::limit($post['caption'], 150) }}</p>
@endif
<p class="post-stats">
{{ $post['likeCount'] ?? 0 }} likes · {{ $post['commentsCount'] ?? 0 }} comments
</p>
</div>
@endforeach
</div>
@if ($pagination && $pagination['hasMore'])
<a href="{{ route('instagram', ['cursor' => $pagination['nextCursor']]) }}">Older posts</a>
@endif
</div>
caption, altText, likeCount and commentsCount can all be null.
Caching
Feedframer caches responses at its edge for up to an hour and purges that cache when new posts arrive. A local cache on top saves the network round trip on every page view.
cachedFeed() in the service wraps Cache::remember() and takes its TTL in seconds. The cache key includes the API key and the sorted parameters, so each account and each page gets its own entry:
use App\Services\FeedframerService;
$feed = app(FeedframerService::class)->cachedFeed(['filter[type]' => 'IMAGE'], 1800);
To replace an entry with fresh data, call warmCache() with the same parameters. Errors are thrown before anything is stored, so they are never cached. Avoid Cache::flush(): it clears your whole application cache.
Scheduled Updates
Warm the cache from the scheduler so no visitor waits for the API. In Laravel 11 and 12, schedules live in routes/console.php:
// routes/console.php
use App\Services\FeedframerService;
use Illuminate\Support\Facades\Schedule;
Schedule::call(fn () => app(FeedframerService::class)->warmCache(['page[size]' => 6]))
->hourly()
->name('feedframer:warm-cache')
->withoutOverlapping();
Use the same parameters as the page that reads the cache (here the controller's first page, ['page[size]' => 6]), or the keys won't match. Feedframer refreshes Premium accounts hourly and Free accounts daily, so running more often than hourly gains little. The server needs the usual * * * * * php artisan schedule:run cron entry.
GraphQL Integration
GraphQL has one query for your feed, me, which returns the profile, posts and pagination. Put api_key in the URL query string; the service's graphql() method does that for you.
use App\Services\FeedframerService;
$query = <<<'GRAPHQL'
query LatestImages($first: Int, $cursor: String) {
me(first: $first, cursor: $cursor, type: "IMAGE") {
username
posts {
id
instagramPostId
type
caption
mediaUrl
thumbnailUrl
permalink
publishedAt
}
pagination {
nextCursor
hasMore
}
}
}
GRAPHQL;
$data = app(FeedframerService::class)->graphql($query, ['first' => 6]);
$posts = $data['me']['posts'];
$nextCursor = $data['me']['pagination']['nextCursor'];
Some field names differ from REST: GraphQL uses type and publishedAt where REST uses mediaType and timestamp. The GraphQL id is Feedframer's own id; the Instagram id is instagramPostId. first defaults to your plan's limit and can't go above it.
Without the service, call the endpoint directly:
use Illuminate\Support\Facades\Http;
$response = Http::withQueryParameters(['api_key' => config('services.feedframer.api_key')])
->post(config('services.feedframer.base_url').'/graphql', [
'query' => '{ me { username posts { id type mediaUrl thumbnailUrl publishedAt } } }',
]);
$posts = $response->json('data.me.posts');
A missing or invalid key returns HTTP 401 with the REST error body above. Other problems, such as an invalid type, return HTTP 200 with an errors array. See the GraphQL API reference for the full schema.
Filtering
In these examples, $feedframer is a FeedframerService, for example injected into a controller method.
Filter by Type
// Images only
$images = $feedframer->feed(['filter[type]' => 'IMAGE']);
// Videos only. REELS returns the same posts, because Instagram reports reels as videos.
$videos = $feedframer->feed(['filter[type]' => 'VIDEO']);
filter[type] accepts IMAGE, VIDEO, CAROUSEL_ALBUM and REELS. The mediaType of each returned post is IMAGE, VIDEO or CAROUSEL_ALBUM. Don't send an empty filter[type]; it returns a 422.
Filter by Date Range
$posts = $feedframer->feed([
'filter[publishedAfter]' => now()->subDays(30)->toDateString(),
'filter[publishedBefore]' => now()->toIso8601String(),
]);
Both filters are inclusive. A plain date such as 2026-01-15 means midnight UTC for publishedAfter and the whole day for publishedBefore.
On a Free key, filters apply within your 6 most recent posts, so a filter can return fewer than 6 posts or none. See Filtering & Sorting.
Pagination
Premium keys page through every stored post with cursors. Pass pagination.nextCursor back as page[cursor] and stop when pagination.hasMore is false. The controller above does this one page at a time; allPosts() in the service fetches everything:
use App\Services\FeedframerService;
$posts = app(FeedframerService::class)->allPosts();
On Premium, each request returns up to 100 posts. On Free, the first response already has hasMore: false and nextCursor: null, so allPosts() makes one request and returns your 6 most recent posts, and the "Older posts" link in the Blade view never appears.
Cursors are opaque strings. Pass them back unchanged. See Pagination.
Error Handling
The service throws FeedframerException for every error. Switch on errorCode:
use App\Services\FeedframerException;
use Illuminate\Support\Facades\Log;
try {
$feed = $feedframer->feed(['page[size]' => 500]);
} catch (FeedframerException $e) {
match ($e->errorCode) {
// Wrong key, regenerated key, or disconnected account.
'MISSING_API_KEY', 'INVALID_API_KEY' => Log::error('Feedframer: check FEEDFRAMER_API_KEY'),
// One entry per bad parameter, e.g. "page[size]: The page[size] field must not be greater than 100."
'VALIDATION_ERROR' => collect($e->errors)->each(
fn (array $error) => Log::warning('Feedframer: '.($error['source']['parameter'] ?? '?').': '.$error['detail'])
),
// INTERNAL_SERVER_ERROR or CONNECTION_FAILED: try again later.
default => Log::error("Feedframer: {$e->errorCode}: {$e->getMessage()}"),
};
$feed = ['posts' => [], 'pagination' => null];
}
page[size]=500 is above the maximum of 100, so this request returns a 422.
Feedframer has no rate limits, so there are no 429 responses to handle. See Error Handling for every code.
View Composer
Share posts with a partial that appears on many pages. In Laravel 11 and 12, register the composer in AppServiceProvider:
// app/Providers/AppServiceProvider.php
use App\Services\FeedframerException;
use App\Services\FeedframerService;
use Illuminate\Support\Facades\View;
public function boot(): void
{
View::composer('partials.instagram-sidebar', function ($view) {
try {
$posts = app(FeedframerService::class)->cachedFeed(['page[size]' => 6])['posts'];
} catch (FeedframerException $e) {
report($e);
$posts = [];
}
$view->with('instagramPosts', $posts);
});
}
{{-- resources/views/partials/instagram-sidebar.blade.php --}}
<div class="instagram-sidebar">
<h3>Latest from Instagram</h3>
<div class="grid grid-cols-2 gap-2">
@foreach ($instagramPosts as $post)
@php
$src = $post['sizes']['320']
?? ($post['mediaType'] === 'VIDEO' ? $post['thumbnailUrl'] : $post['mediaUrl']);
@endphp
@if ($src)
<a href="{{ $post['permalink'] }}" target="_blank" rel="noopener">
<img src="{{ $src }}" alt="{{ $post['altText'] ?? 'Instagram post' }}" class="rounded" loading="lazy">
</a>
@endif
@endforeach
</div>
</div>
Testing
Fake Feedframer's responses with Http::fake(). The fake below matches the real response shape, including a Premium sizes object and a video post with an .mp4 mediaUrl.
<?php
// tests/Feature/InstagramFeedTest.php
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config([
'services.feedframer.api_key' => 'test-key',
'services.feedframer.base_url' => 'https://feedframer.com',
]);
});
test('displays the instagram feed', function () {
Http::fake([
'feedframer.com/api/v1/me*' => Http::response([
'username' => 'your_username',
'name' => 'Your Name',
'biography' => 'Your bio text',
'website' => 'https://example.com',
'profilePictureUrl' => 'https://cdn.feedframer.com/your_username/123_profile.jpg',
'followersCount' => 1234,
'followsCount' => 567,
'mediaCount' => 89,
'posts' => [
[
'id' => '18123456789012345',
'caption' => 'Sunset over the harbour',
'altText' => null,
'mediaType' => 'IMAGE',
'mediaUrl' => 'https://cdn.feedframer.com/your_username/18123456789012345_media.jpg',
'thumbnailUrl' => null,
'permalink' => 'https://www.instagram.com/p/ABC123def45/',
'timestamp' => '2026-01-15T18:30:00+00:00',
'likeCount' => 1542,
'commentsCount' => 87,
'sizes' => [
'320' => 'https://cdn.feedframer.com/cdn-cgi/image/width=320,format=jpg/your_username/18123456789012345_media.jpg',
'768' => 'https://cdn.feedframer.com/cdn-cgi/image/width=768,format=jpg/your_username/18123456789012345_media.jpg',
'960' => 'https://cdn.feedframer.com/cdn-cgi/image/width=960,format=jpg/your_username/18123456789012345_media.jpg',
'1200' => 'https://cdn.feedframer.com/cdn-cgi/image/width=1200,format=jpg/your_username/18123456789012345_media.jpg',
],
],
[
'id' => '18123456789012346',
'caption' => null,
'altText' => null,
'mediaType' => 'VIDEO',
'mediaUrl' => 'https://cdn.feedframer.com/your_username/18123456789012346_media.mp4',
'thumbnailUrl' => 'https://cdn.feedframer.com/your_username/18123456789012346_thumb.jpg',
'permalink' => 'https://www.instagram.com/reel/DEF456ghi78/',
'timestamp' => '2026-01-14T09:00:00+00:00',
'likeCount' => null,
'commentsCount' => 3,
],
],
'pagination' => [
'nextCursor' => 'eyJwdWJsaXNoZWRfYXQiOiIyMDI2LTAxLTE0In0',
'prevCursor' => null,
'hasMore' => true,
'perPage' => 6,
],
]),
]);
$this->get('/instagram')
->assertOk()
->assertSee('Sunset over the harbour')
->assertSee('width=768', false)
->assertSee('18123456789012346_thumb.jpg', false)
->assertDontSee('.mp4', false)
->assertSee('Older posts');
Http::assertSent(function ($request) {
parse_str(parse_url($request->url(), PHP_URL_QUERY), $query);
return $query['api_key'] === 'test-key' && $query['page']['size'] === '6';
});
});
test('shows a fallback when the api key is invalid', function () {
Http::fake([
'feedframer.com/*' => Http::response([
'errors' => [[
'status' => '401',
'code' => 'INVALID_API_KEY',
'title' => 'Authentication Failed',
'detail' => 'The provided API key is invalid.',
]],
], 401, ['Content-Type' => 'application/vnd.api+json']),
]);
$this->get('/instagram')
->assertOk()
->assertSee('Unable to load Instagram posts');
});
Laravel's default phpunit.xml uses the array cache store for tests, so cached responses don't leak between tests.
Best Practices
- Keep API calls in one service. Controllers, composers and scheduled tasks then share caching and error handling.
- Cache locally. Feedframer data changes at most hourly on Premium and daily on Free. Include the API key in cache keys.
- Handle errors. Show fallback content when the API is unreachable, and log the error
code. - Keep the key in
.env. Read it withconfig(), neverenv(), outside config files. - Write size-agnostic code. Leave out
page[size]or keep it at 6 or below so the same code works on Free and Premium.