facebook/php-business-sdk
The Facebook Business SDK for PHP bundles multiple Facebook APIs, including Marketing, Pages, Business Manager, and Instagram, into one SDK to simplify implementation and ongoing maintenance for partners serving business clients.
The SDK is a plain Composer library, not a Laravel package: no service provider, no facade, no config file, no published migrations. Composer install is the whole "installation", and everything after that is yours to wire up. Plan for a thin wrapper layer from day one — the autogenerated object graph is enormous and you do not want FacebookAds\Object\* types leaking into controllers or Eloquent models.
Requires PHP 8.0+, which is fine for Laravel 9/10/11/12 apps.
composer require facebook/php-business-sdk
Configuration in config/services.php plus .env:
// config/services.php
'facebook' => [
'app_id' => env('FB_APP_ID'),
'app_secret' => env('FB_APP_SECRET'),
'access_token' => env('FB_ACCESS_TOKEN'),
'ad_account_id'=> env('FB_AD_ACCOUNT_ID'),
'graph_version'=> env('FB_GRAPH_VERSION', 'v21.0'),
],
Bind the API once in a provider, but read the caveat in Gotchas about the singleton and long-running workers:
// app/Providers/MetaServiceProvider.php
public function register(): void
{
$this->app->scoped(\FacebookAds\Api::class, function () {
$api = \FacebookAds\Api::init(
config('services.facebook.app_id'),
config('services.facebook.app_secret'),
config('services.facebook.access_token'),
);
$api->getSession()->setDefaultGraphVersion(
config('services.facebook.graph_version')
);
return $api;
});
}
Where to look first, in this order:
FacebookAds\Api and FacebookAds\Session — auth, version, logger, last response.FacebookAds\Object\AdAccount — the entry point for almost everything (campaigns, adsets, ads, insights, custom audiences, creatives).FacebookAds\Object\Fields\* — the enum-ish field constants; the fastest way to discover what a given object exposes.FacebookAds\Cursor — all list endpoints return one; understand it before writing loops.First use case: read-only reporting. Pull campaigns and a 30-day insights snapshot for one ad account into a dashboard before touching any write path. Writes are where the money and the support tickets are, and you want the auth, versioning, and error handling proven first.
use FacebookAds\Object\AdAccount;
$account = new AdAccount(config('services.facebook.ad_account_id')); // must include "act_" prefix
$campaigns = $account->getCampaigns(['id', 'name', 'status', 'objective']);
foreach ($campaigns as $campaign) {
// ...
}
Wrap the SDK in a domain service, not a facade. A single App\Services\Meta\MetaAdsClient that returns arrays/DTOs, throws your own exceptions, and takes the account ID as a parameter keeps the SDK swappable and keeps FacebookAds imports to a handful of files. This also makes testing tractable, because the SDK is hostile to mocking at the HTTP layer.
Multi-tenant tokens: never rely on the singleton. For agencies or SaaS where each tenant has its own ad account and token, construct a Session/Api per tenant and inject it into the objects rather than calling Api::init() globally:
$session = new \FacebookAds\Session($appId, $appSecret, $tenant->fb_access_token);
$api = new \FacebookAds\Api($session);
$account = new \FacebookAds\Object\AdAccount($tenant->fb_ad_account_id, null, $api);
This is the pattern that survives queue workers, Octane, and concurrent requests.
Bulk reads: page manually in jobs, not with implicit fetch. setUseImplicitFetch(true) is convenient in a tinker session but in a job it means unbounded HTTP requests and the whole result set accumulated in memory. Instead, loop with fetchAfter() and persist the after cursor so an interrupted job can resume:
$cursor = $account->getCampaigns(['id', 'name', 'status'], ['limit' => 100]);
do {
foreach ($cursor as $campaign) {
Campaign::updateOrCreate(['fb_id' => $campaign->id], [...]);
}
Cache::put("meta:campaigns:{$account->id}:cursor", $cursor->getAfter());
$cursor->fetchAfter();
} while ($cursor->valid());
Insights: treat them as ETL, not a page-load query. Insights calls are the slowest and most rate-limit-sensitive part of the API. Pull them on a schedule into your own tables, request only the fields you render, and use time_increment to get daily rows in one call instead of 30 calls:
$cursor = $account->getInsights(
['campaign_name', 'spend', 'impressions', 'clicks', 'actions', 'date_start'],
[
'level' => 'campaign',
'date_preset' => 'last_30d',
'time_increment' => 1,
]
);
For very large accounts or long ranges, use getInsightsAsync() (returns an AdReportRun) and poll it from a queued job rather than holding an HTTP request open.
Batch requests for fan-out writes. Pausing 200 campaigns one call at a time will burn your rate limit and take minutes. The batch endpoint lets you send up to 50 sub-requests per call:
$batch = $api->newBatch();
foreach ($campaignIds as $id) {
$batch->addRequest(
(new \FacebookAds\Object\Campaign($id))->updateSelf([], ['status' => 'PAUSED'])
);
}
$responses = $batch->execute();
Conversions API: capture request context in the request, send from the queue. The bundled Parameter Builder fills fbc, fbp, client_ip_address, event_source_url, and referrer_url from a server array — but only if you give it the web server array. In a queued job $_SERVER is the CLI environment and you'll silently send garbage.
// In the request/controller — snapshot what you need
SendPurchaseEvent::dispatch(
orderId: $order->id,
serverBag: $request->server->all(),
ip: $request->ip(),
userAgent: $request->userAgent(),
);
// In the job
$event = (new Event())
->setEventName('Purchase')
->setEventTime($order->paid_at->timestamp)
->setEventId("order-{$order->id}") // dedupe against browser pixel
->setEventSourceUrl($order->url)
->setActionSource(ActionSource::WEBSITE)
->setUserData((new UserData())->setEmail($order->email)->setClientIpAddress($ip))
->setCustomData((new CustomData())->setCurrency('usd')->setValue($order->total))
->setRequestContext($serverBag);
(new EventRequest($pixelId))->setEvents([$event])->execute();
Always set event_id so CAPI and the browser pixel deduplicate. Queue the send with retries ($tries = 5, exponential backoff) — CAPI is fire-and-forget from the user's perspective and should never block checkout.
Error handling and retries. Every Graph error surfaces as FacebookAds\Http\Exception\RequestException (with subclasses for auth). Branch on getErrorCode() / getErrorSubcode(), and use getIsTransient() to decide whether the job should retry:
try {
$account->createCampaign([], $payload);
} catch (\FacebookAds\Http\Exception\RequestException $e) {
if ($e->getIsTransient()) {
throw $e; // let the queue retry
}
Log::warning('Meta rejected campaign create', [
'code' => $e->getErrorCode(),
'subcode' => $e->getErrorSubcode(),
'message' => $e->getErrorUserMessage(),
]);
}
Testing. Don't try to mock the SDK's internals. Fake at your own wrapper boundary (MetaAdsClient interface bound to a fake in the container), and keep a separate small suite of integration tests tagged @group meta that run against a test ad account with an explicit env flag. The SDK's cURL client is injectable via Session, but it's rarely worth the ceremony compared to faking your own layer.
Api::init() is a mutable static singleton. It is global process state. Under Octane, in long-running workers, or in any multi-tenant app, whichever request last called init() wins and you will send one tenant's token with another tenant's account ID. Use $this->app->scoped() for the binding, pass explicit Api instances to objects, and add an Octane RequestReceived/RequestTerminated listener that resets it if you must use the singleton.
Cursor::count() and iteration are lazy. Constructing the cursor doesn't fetch; count() may trigger a fetch. Don't call count() in a loop or to check emptiness before iterating — check valid()/current() instead. And cursors retain every fetched object, so a 50k-row insights pull is a memory problem waiting to happen.
setDefaultUseImplicitFetch() is static too. If a test or a console command flips it, it stays flipped for the process. Reset it in tearDown().
Budgets are in minor units and the account's currency. DAILY_BUDGET => 150 is $1.50, not $150. This is the single most common production incident with this SDK. Also, start_time must be in the ad account's timezone, and the offset format trips people up — \DateTime::ISO8601 produces +0000 while \DateTime::ATOM produces +00:00; be consistent and test against the account's real timezone.
Ad account IDs need the act_ prefix; campaign/adset/ad IDs do not. Mixing them up yields "unsupported get request" errors that read as permission problems.
Tokens expire and the SDK won't tell you until it's too late. Code 190 means invalid/expired token. For server-to-server work use a System User token (long-lived, doesn't expire on password change) and store it encrypted; for user tokens, call /debug_token on a schedule and alert before expiry rather than discovering it during a campaign launch.
Pin the SDK version and the Graph version independently. The package ships a default Graph API version that can lag or jump between minor releases, and composer update can silently move you across a breaking API version. Constrain in composer.json (e.g. "facebook/php-business-sdk": "~21.0") and set the version explicitly on the session so an SDK bump doesn't move your API surface.
Rate limits are your responsibility. The SDK does not back off. Read X-Business-Use-Case-Usage and X-App-Usage from $api->getLastResponse()->getHeaders(), store the utilization in cache, and have jobs bail or delay when a use-case is above ~80%. Pair this with a RateLimited job middleware so you're not retrying into a wall.
Never log with CurlLogger in production. It prints the full URL including access_token. Use it for local debugging of a failing call, then set a NullLogger. The raw-cURL trick in the README (CurlLogger + a single read(['id'])) is genuinely the fastest way to distinguish "SDK bug" from "API/permission bug" — keep it as a snippet in your team docs.
Don't pre-hash Conversions API user data. The bundled Parameter Builder normalizes (trim/lowercase) and SHA-256s email/phone itself. If you hand it an already-hashed value it will hash the hash and your match rates quietly collapse. Pass raw values.
CAPI returns HTTP 200 on partial failure. Check events_received in the response body, not just the status. A misconfigured pixel ID or an action_source typo yields a successful-looking response with zero events ingested. Send with test_event_code in staging and verify in Events Manager before shipping.
event_time must be within 7 days of send for CAPI. Backfilling historical orders beyond that window silently drops them — worth an explicit guard in your job.
Deletes are mostly status changes. deleteSelf() on campaigns/adsets often fails or is irreversible for the wrong status; prefer updating status to PAUSED/ARCHIVED, which is also what most clients actually mean by "delete". Reserve deleteSelf() for creatives and audiences.
Writes are not idempotent. If a create call times out after Meta accepted it, a naive retry duplicates the campaign. Store a local mapping (external_id + your own idempotency key in a unique index) and check-before-create, or retry only on getIsTransient() plus a lookup.
Field enum classes lag the API. FacebookAds\Object\Fields\* won't have brand-new fields. Falling back to plain string literals for those is fine and expected — don't file it as a bug.
Licensing. The package is NOASSERTION — it's the Facebook Platform license, not MIT/Apache. Read it before shipping in a closed-source or commercial product; it's permissive in practice but not an OSI-standard license, and your legal/compliance review will ask.
Webhooks are not part of this SDK. Lead Ads and Page webhooks need you to verify X-Hub-Signature-256 yourself with hash_equals(hash_hmac('sha256', $rawBody, $appSecret), $header) against the raw request body (not $request->all()). Use the SDK only for the follow-up Graph calls.
Vendor size is real. The autogenerated object model adds thousands of classes to vendor/. It's harmless at runtime but inflates deploy artifacts and IDE indexing; exclude it from static analysis rather than trying to type it.
How can I help you explore Laravel packages today?