microsoft/bingads
The official Bing Ads PHP SDK provides proxy classes for all Bing Ads API web services, handling OAuth authentication and low-level SOAP details so you can build Bing Ads applications in PHP. Install via Composer as microsoft/bingads, autoload with PSR-4, and manage campaigns, ads, and reports progr...
Installation
composer require microsoft/bingads:^13.0.30.1
Ensure extension=soap is enabled in php.ini. The 13.0.30.1 SDK targets API revision 2026-10-12 — older proxy versions will not expose the new AI/media/exclusion members described below.
First Authentication
Use the OAuthDesktopMobileAuthCodeGrant for local development:
use Microsoft\BingAds\V13\Authentication\OAuthDesktopMobileAuthCodeGrant;
use Microsoft\BingAds\V13\Authentication\AuthenticationHelper;
$authenticationHelper = new AuthenticationHelper();
$authCodeGrant = new OAuthDesktopMobileAuthCodeGrant(
$authenticationHelper->GetDeveloperToken(),
$authenticationHelper->GetRefreshToken(),
$authenticationHelper->GetAccessToken(),
$authenticationHelper->GetAuthCode()
);
$authCodeGrant->Authenticate();
First API Call Verify the SDK surface you actually got before writing feature code:
use Microsoft\BingAds\V13\CampaignManagement\CampaignManagementServiceClient;
$serviceClient = new CampaignManagementServiceClient(
$authCodeGrant->GetAccessToken(),
$authCodeGrant->GetRefreshToken(),
$authCodeGrant->GetCustomerId()
);
// Confirms CustomerId/CustomerAccountId headers are wired correctly.
$accounts = $serviceClient->GetCustomerAccounts();
First "new-in-13.0.30.1" Use Case
The lowest-risk way to adopt the release is a media update loop: upload media with AI content labels, then swap creative in place with UpdateMedia instead of delete + re-add (which resets performance history and ad IDs).
$media = $serviceClient->AddMedia([$imageMedia])->Media;
$imageMedia->Id = $media[0]->Id;
$imageMedia->AiContentLabel = 'Generated'; // confirm enum name in your proxy
$serviceClient->UpdateMedia([$imageMedia]);
vendor/microsoft/bingads/src/V13/CampaignManagement/ — inspect the generated proxies for UpdateMedia, AI content label fields, AI prompt audience and campaign term exclusion entitiesvendor/microsoft/bingads/src/V13/Bulk/ — inspect new bulk column mappings (parent customer number / related account)Pattern: Initialize once per request, but always read credentials from config — not from the constructor arguments of the auth grant.
// app/Services/BingAdsService.php
class BingAdsService {
private CampaignManagementServiceClient $serviceClient;
public function __construct(OAuthDesktopMobileAuthCodeGrant $auth) {
$this->serviceClient = new CampaignManagementServiceClient(
$auth->GetAccessToken(),
$auth->GetRefreshToken(),
$auth->GetCustomerId()
);
$this->serviceClient->SetCustomerAccountId(config('bingads.account_id'));
}
public function client(): CampaignManagementServiceClient {
return $this->serviceClient;
}
}
Pattern: Treat disclosure as a first-class attribute of every uploaded asset, set at creation time, and persist it locally so you can audit and re-assert it on update.
class MediaIngestService
{
public function upload(UploadedFile $file, bool $aiGenerated, array $detection = []): int
{
$media = new Image();
$media->Type = 'Image';
$media->Data = base64_encode(file_get_contents($file->path()));
// AI content label + detection metadata (verify exact property
// names against your installed proxy before shipping).
$media->AiContentLabel = $aiGenerated ? 'Generated' : 'None';
$media->DetectionMetadata = json_encode($detection); // e.g. C2PA manifest digest
$result = app(BingAdsService::class)->client()->AddMedia([$media]);
if (!empty($result->PartialErrors)) {
throw new MediaUploadException($result->PartialErrors[0]->Message);
}
return $result->Media[0]->Id;
}
}
Store the label + metadata you sent in your own media_assets table — you cannot rely on round-tripping it back in every GetMediaMetaData response, and ad reviewers may ask.
Pattern: Media IDs are referenced by ads and asset groups. Prefer UpdateMedia over delete/re-add so downstream references stay valid.
public function replaceCreative(int $mediaId, UploadedFile $newFile): void
{
$media = new Image();
$media->Id = $mediaId; // ID is what makes it an update
$media->Type = 'Image';
$media->Data = base64_encode(file_get_contents($newFile->path()));
$result = $this->client()->UpdateMedia([$media]);
// UpdateMedia is per-item: partial failures do NOT throw.
foreach ($result->PartialErrors ?? [] as $error) {
Log::warning('Media update failed', [
'media_id' => $mediaId,
'code' => $error->Code,
'message' => $error->Message,
]);
}
}
Wrap this in a queued job — media validation (aspect ratio, file size, text-in-image) rejects individual items and you want retries with backoff, not a 500 in a request cycle.
Pattern: Model AI prompt audiences as a normal audience with an extra payload, so existing audience-association code keeps working.
public function createAiPromptAudience(string $name, string $prompt, string $promptType = 'SearchTerm'): int
{
$audience = new Audience();
$audience->Name = $name;
$audience->Type = 'AiPrompt'; // confirm constant in your proxy
$audience->AiPrompt = $prompt; // the natural-language seed
$audience->AiPromptType = $promptType; // e.g. intent / search term expansion
$result = $this->client()->AddAudiences([$audience]);
return $result->AudienceIds[0];
}
Keep prompts in a versioned config or DB row. Prompt-triggered audiences change delivery over time, so you want an audit trail of which prompt a campaign targeted in which month.
Pattern: Sync exclusions declaratively from a review queue; the entity is additive, so use a set-diff rather than blind re-adds.
public function syncTermExclusions(int $campaignId, array $terms): void
{
$existing = $this->client()->GetCampaignTermExclusions($campaignId)->CampaignTermExclusions ?? [];
$have = collect($existing)->pluck('Text')->map(fn ($t) => strtolower($t))->all();
$want = collect($terms)->map(fn ($t) => strtolower(trim($t)))->unique();
$toAdd = $want->diff($have)->values()->all();
if ($toAdd) {
$this->client()->AddCampaignTermExclusions($campaignId, $toAdd);
}
}
Exclusions are campaign-scoped, so an account-wide "blocklist" must be fanned out — do that in a job with a per-campaign lock to avoid duplicate concurrent adds.
Use BulkService for large-scale updates:
$bulkService = new BulkServiceClient($auth);
$bulkOperation = new BulkOperationHeader();
$bulkOperation->Operation = BulkOperationType::Add;
$bulkOperation->Objects = [$campaign1, $campaign2];
$bulkResponse = $bulkService->ExecuteBulkOperation($bulkOperation);
New in 13.0.30.1: the bulk schema adds mappings for parent customer number and related account, letting you upload hierarchical account structures in one file rather than per-account API calls. Use these for agency/multi-account provisioning:
// Column set for the new mappings — verify names against the
// BulkServiceManager schema in your installed version.
$columns = [
'Type', 'Status', 'Parent Customer Number', 'Related Account',
'Account Name', 'Customer Id', 'Currency Code', 'Time Zone',
];
Keep the parent customer number as a string in your models — leading zeros and 8+ digit values will be truncated by an int cast.
Generate and download reports:
$reportingService = new ReportingServiceClient($auth);
$reportRequest = new ReportRequest();
$reportRequest->ReportName = "CampaignPerformanceReport";
$reportRequest->Aggregation = ReportAggregation::Daily;
$reportRequest->Columns = ["CampaignName", "Impressions", "Clicks"];
$downloadRequest = new DownloadReportRequest($reportRequest);
$reportDownload = $reportingService->DownloadReport($downloadRequest);
If you report on creative or asset performance, confirm whether the 2026-10-12 revision adds AI-content-label columns to your report type before assuming you can segment by it — report columns lag feature releases.
try {
$response = $serviceClient->GetCampaigns();
} catch (ApiException $e) {
if ($e->getCode() === 401) {
$auth->RefreshToken();
// retry once via Laravel's retry() helper
}
throw $e;
}
Remember the split: service-level errors throw; partial errors (PartialErrors, BatchErrors) arrive in a 200 response and must be inspected per item. UpdateMedia, AddMedia and bulk uploads are all partial-error surfaces.
BINGADS_DEVELOPER_TOKEN=your_dev_token
BINGADS_REFRESH_TOKEN=your_refresh_token
BINGADS_CUSTOMER_ID=1234567
BINGADS_CUSTOMER_ACCOUNT_ID=1234567
BINGADS_PARENT_CUSTOMER_NUMBER=00123456
Load via a dedicated config/bingads.php and cache it; do not call env() outside config files — it returns null once config:cache has run.
Generated proxies changed shape
Media, Audience, bulk records). If you subclass generated proxies (e.g. CustomCampaignServiceClient), re-check __construct signatures and any toArray/serializer overrides after the bump."microsoft/bingads": "13.0.30.1"
git diff vendor/microsoft/bingads/src/V13/CampaignManagement/
AI content labels are not retroactive
UpdateMedia for existing assets rather than assuming they inherit a label.media_assets table, and log the PartialErrors per media ID.UpdateMedia is not a "create-or-update"
Image without Id to UpdateMedia fails per-item; sending an unknown Id fails per-item. There is no upsert semantics.AddMedia when absent.Detection metadata must be serialized predictably
json_encode() output.AI prompt audiences are volatile
Campaign term exclusions are additive per campaign
Parent customer number is a string
int in Eloquent silently breaks accounts with leading zeros, and the bulk mapping will fail with a non-obvious "account not found".Token Expiry
401 Unauthorized by refreshing the token:
if ($e->getCode() === 401) {
$auth->RefreshToken();
retry(3, fn () => $serviceClient->GetCampaigns(), 200);
}
Sandbox vs. Production
login.windows-ppe.net (not live-int.com as of v13.0.10).Scopes
msads.manage (required for MFA).oAuthScope in the ServiceClient constructor:
$serviceClient = new CampaignManagementServiceClient(
$auth->GetAccessToken(),
$auth->GetRefreshToken(),
$auth->GetCustomerId(),
['oAuthScope' => 'https://bingads.microsoft.com/Api/Advertiser/Manage']
);
Bulk Operations
ValidateBulkOperation to pre-check:
$validation = $bulkService->ValidateBulkOperation($bulkOperation);
if ($validation->HasErrors) {
throw new \RuntimeException("Bulk operation validation failed");
}
Reporting Delays
OfflineConversionReport) take 24+ hours to populate.reportReady() check with exponential backoff.SOAP Headers
CustomerId or CustomerAccountId in headers causes 400 Bad Request.$serviceClient->GetServiceClient()->GetSoapClient()->__getLastRequestHeaders();
Rate Limiting
throttle middleware or a queue:
Queue::later(now()->addSeconds(1), fn () => $serviceClient->GetCampaigns());
AddMedia calls and throttle the queue worker.Enable SOAP Debugging
$soap = $serviceClient->GetServiceClient()->GetSoapClient();
$soap->setDebug(true);
$lastRequest = $soap->__getLastRequest();
$lastResponse = $soap->__getLastResponse();
UpdateMedia payloads contain base64 image bytes — truncate before logging.
Log Partial Errors, Not Just Exceptions
foreach ($result->PartialErrors ?? [] as $error) {
Log::warning('BingAds partial error', [
'code' => $error->Code,
'message' => $error->Message,
]);
}
Log Authentication Tokens — Don't
access_token or refresh_token in production.customer_id and timestamp for debugging:
Log::debug("BingAds request for customer {$auth->GetCustomerId()}");
Version Mismatches
InvalidOperationException on the new members, or silently drops them from the serialized SOAP body — which is worse, because the call still "succeeds".Custom Proxies
CampaignManagementServiceClient) to add business logic:
class CustomCampaignServiceClient extends CampaignManagementServiceClient {
public function getActiveCampaigns() {
$campaigns = parent::GetCampaigns();
return array_filter($campaigns, fn ($c) => $c->Status == 'Active');
}
}
Media Asset Registry
media_assets table (media_id, ai_content_label, detection_metadata, uploaded_at, campaign_id). It's the only reliable place to answer "which creatives are AI-generated?" and to drive UpdateMedia backfills.Event Listeners
Queue::failing(function (JobFailed $event) {
if (str_contains($event->exception->getMessage(), '401')) {
$event->job->release(60);
}
});
Model Bindings
class BingAdCampaign extends Model {
protected $fillable = ['name', 'status', 'budget'];
public static function syncFromApi() {
$campaigns = app(BingAdsService::class)->client()->GetCampaigns();
return self::upsert($campaigns->toArray(), ['id']);
}
}
campaign_term_exclusions pivot and an ai_prompt_audiences table alongside it; both are campaign-attached and easy to sync in the same job.Testing
ServiceClient in PHPUnit:
$mockClient = $this->createMock(CampaignManagementServiceClient::class);
$mockClient->method('GetCampaigns')->willReturn(new Campaign[]);
$mockClient->method('UpdateMedia')->willReturn(
(object) ['PartialErrors' => []]
);
$this->app->instance(CampaignManagementServiceClient::class, $mockClient);
PartialErrors and batch errors — the happy path is the least interesting case in this SDK.How can I help you explore Laravel packages today?