scriptdevelop/meta-catalog-manager
A Laravel package for managing Meta (Facebook) product catalogs: sync products, build and upload catalog feeds, handle batch API requests, and keep item data consistent with Meta Commerce Manager.
Install and publish
composer require scriptdevelop/meta-catalog-manager
php artisan vendor:publish --provider="ScriptDevelop\MetaCatalogManager\MetaCatalogManagerServiceProvider"
Because the package has near-zero adoption (2 stars, 0 dependents), treat the README as a starting hypothesis, not documentation. Before you write a line of integration code, spend 15 minutes in vendor/scriptdevelop/meta-catalog-manager/:
src/*ServiceProvider.php — confirms the real config publish tag, whether bindings are singletons or scoped, and which classes are registered as facades/aliases.config/meta-catalog-manager.php (name may differ) — the authoritative list of env keys: Meta app ID/secret, business ID, access token, API version, default catalog ID, logging channel.src/ directory tree — identifies the client/manager entry point, the DTOs or arrays returned, and whether there are separate services per concern (accounts, feeds, inventory, offers, diagnostics).routes/ and src/Console/ — diagnostics endpoints or artisan commands are often the fastest way to confirm credentials work before touching your own code.composer.json — check the illuminate/* and php constraints against your app. A package this small is unlikely to be tested against Laravel 11/12; if the constraint is loose (e.g. ^9.0|^10.0), verify manually.Configuration
META_APP_ID=
META_APP_SECRET=
META_BUSINESS_ID=
META_ACCESS_TOKEN=
META_API_VERSION=v21.0
META_CATALOG_ID=
Use a System User token from Meta Business Manager, not a personal token — personal tokens expire in ~60 days and will silently break your scheduled feed jobs.
First use case — prove the pipe end to end
The highest-value first slice is: one catalog, one product feed, visible in Commerce Manager. Do this in a tinker session or a throwaway artisan command before building anything:
$manager = app(\ScriptDevelop\MetaCatalogManager\MetaCatalogManager::class);
// 1. Can I read the account/catalog? (proves token scope + permissions)
$catalog = $manager->catalog($catalogId)->get();
// 2. Can I push a minimal product?
$manager->products($catalogId)->create([
'retailer_id' => 'SKU-1',
'name' => 'Test Product',
'price' => '19.99 USD',
'availability'=> 'in stock',
'condition' => 'new',
'link' => 'https://example.com/p/1',
'image_link' => 'https://example.com/p/1.jpg',
]);
If step 1 fails with an OAuthException, the problem is token scopes — you need catalog_management and business_management at minimum — not the package. Getting that distinction early saves hours.
Wrap the package; do not call it from controllers
Given the adoption level, assume the public API may shift between patch releases. Put a thin CatalogService of your own in front of it so a breaking change is a one-file fix:
class CatalogService
{
public function __construct(
private MetaCatalogManager $manager,
private CatalogAccountResolver $resolver,
) {}
public function pushProduct(Product $product, string $accountKey): array
{
$account = $this->resolver->for($accountKey);
return $this->manager
->account($account->meta_business_id, $account->access_token)
->products($account->catalog_id)
->updateOrCreate($product->sku, $this->toPayload($product));
}
}
Multi-account: resolve credentials per call, never globally
The package's headline feature is multi-account catalogs. Whatever the global config says, the day-to-day reality is one catalog per storefront/region/tenant. Store business_id, catalog_id, and access_token (encrypted cast) on your own model — e.g. meta_accounts — and pass them explicitly. If the package only reads credentials from config, you'll end up doing:
config(['meta-catalog-manager.access_token' => $account->access_token]);
That pattern is a landmine under Octane, queued jobs, and any concurrent request. If the package doesn't support per-account credentials natively, register it as a scoped binding or instantiate a fresh client per job — and note it as a fork-worthy gap.
Product feeds: build the source of truth in Laravel, not in Meta
Keep products in Eloquent, and treat Meta as a downstream projection. A scheduled command is the cleanest shape:
// app/Console/Commands/SyncCatalogFeed.php
Schedule::command('catalog:sync')->hourly()->withoutOverlapping();
Inside, stream with chunkById(500) and diff against a meta_feed_state table (sku, payload_hash, synced_at, status). Only send rows whose hash changed. Meta catalogs are eventually consistent and re-pushing unchanged products wastes quota and muddies diagnostics — the package's diagnostics output is far more useful when your own diff layer has already removed the noise.
Batch inventory: chunk, queue, throttle
Inventory is the highest-frequency operation and the one most likely to hit rate limits. Pattern:
Product::query()
->where('updated_at', '>', $since)
->chunkById(200, function ($products) use ($account) {
UpdateCatalogInventory::dispatch($account->id, $products->pluck('sku'));
});
class UpdateCatalogInventory implements ShouldQueue, ShouldBeUnique
{
public $uniqueFor = 3600;
public function middleware(): array
{
return [(new RateLimited('meta-api'))->releaseAfter(30)];
}
}
Define the meta-api limiter in AppServiceProvider from your observed quota rather than a guess. Meta's batch endpoints have per-request item ceilings; keep your chunk sizes comfortably under them and make the chunk size a config value so you can tune it without a deploy.
Localized catalogs: model the locale explicitly
If you sell in multiple locales, the cleanest model is a localized feed per locale derived from a base product, keyed by retailer_id suffixed or namespaced by locale (SKU-1__de-DE). Keep translations in a product_translations table, generate one payload per locale, and let each locale map to its own catalog or override feed. Do not attempt to mutate a single product's localized fields repeatedly — the write amplification and race conditions are worse than the duplication.
Diagnostics as a first-class job
Wire the package's diagnostics into your own health surface: a daily scheduled command that checks token expiry, per-catalog item counts, and last successful feed timestamp, writing to a meta_health_checks table. Alert on drift (e.g. Laravel item count vs. Meta product_count differing by >2%). This is what makes the package genuinely operational rather than a one-shot uploader — and it's the part most integrations skip.
Testing
Use Http::fake() at the boundary and assert on the outbound payload shape, not on the package's internals. Snapshot-test your toPayload() output — Meta rejects on field-level detail, and a schema regression is the most common production break.
Adoption risk is real — pin and audit
Two stars and zero dependents means no community bug reports to learn from. Pin an exact version ("scriptdevelop/meta-catalog-manager": "1.2.3"), read the diff before every bump, and check the last commit date. If the repo has been dormant for 12+ months, assume you are the maintainer of anything you rely on, and budget for possibly inlining the ~few-hundred lines you actually use.
Long-lived tokens are the #1 production incident
System User tokens can still be invalidated by app permission changes, business verification lapses, or password/2FA events on the owning admin. Store an expiry, run a daily validity probe, and alert before the feed job starts failing. Never store the token in a config file that gets committed.
Config cache breaks runtime env()
If the package calls env() inside its service provider or classes, php artisan config:cache will freeze those values to whatever existed at cache time — or return null. Check for env( calls in src/. If present, prefer setting everything via the published config file and re-running config:cache after env changes.
API version drift
Pin META_API_VERSION explicitly. Unpinned or defaulted values get you deprecated endpoints with cryptic errors months later. Read the version out of config, never hardcode it in your own code.
Uploads are asynchronous
Product feed and batch uploads return a session/handle, not a completed result. Poll the status, and don't assert success right after the call. Design your synced_at to represent confirmed ingestion, and add a reconciliation job that re-checks anything stuck in pending past a threshold.
Error handling is where packages this size are weakest
Catch at the boundary and normalize. Log Meta's fbtrace_id from the error body on every failure — Meta support will not help you without it, and it's usually the only way to correlate your request with their logs. Distinguish clearly between:
OAuthException, code 200/190) — fix credentials, do not retry.price or availability) — dead-letter, alert, do not retry; retrying a payload Meta considers invalid just burns quota.Localization fields are stricter than they look
price must include the currency code; availability and condition are enumerated strings; image_link must be a publicly reachable absolute URL (Meta fetches it — localhost and signed/expiring URLs will fail). These fail on Meta's side, not the package's, so validate them in a FormRequest or a toPayload() guard before dispatch.
Extension points to check for
Look for a bindable interface (e.g. an HTTP client contract) in the service provider. If one exists, bind your own implementation in a service provider so you can inject retry logic, logging, and Http::preventStrayRequests() in tests without forking. If none exists, that's your single best fork candidate — a small, isolated change with high operational payoff.
Debugging checklist when nothing works, in order
curl against graph.facebook.com with the same token → isolates package vs. credentials.error_user_title/error_user_msg, which are far clearer than message.How can I help you explore Laravel packages today?