symfony/ai-hugging-face-platform
The Symfony AI HuggingFace bridge integrates the HuggingFace Inference API, giving access to thousands of models across multiple providers. It supports 40+ AI tasks like chat, vision, audio, and embeddings, with model discovery and type-safe results.
Know what you're wiring up first. This is a Symfony AI bridge, not a Laravel package. There is no config/ai.php to publish, no facade, and no auto-discovery. It gives you Factory::createPlatform() plus Task/Provider constants, and you own the Laravel-side plumbing. Budget 15 minutes of setup, not 2.
Install:
composer require symfony/ai-hugging-face-platform symfony/http-client
You need symfony/http-client because Factory::createPlatform() expects a Symfony\Contracts\HttpClient\HttpClientInterface — Laravel's Http facade is not compatible. The Symfony client coexists fine inside a Laravel app.
Config (keep env() out of your app code, Laravel-style):
// config/services.php
'huggingface' => [
'key' => env('HF_TOKEN'),
'provider' => env('HF_PROVIDER', 'hf-inference'),
'timeout' => env('HF_TIMEOUT', 30),
],
Bind the platform as a singleton so you don't rebuild the client per request:
// app/Providers/AiServiceProvider.php
public function register(): void
{
$this->app->singleton(PlatformInterface::class, function ($app) {
$cfg = config('services.huggingface');
return Factory::createPlatform(
apiKey: $cfg['key'],
provider: $cfg['provider'],
httpClient: new HttpClient(['timeout' => $cfg['timeout']]),
);
});
}
Wrap it behind your own tiny interface (App\Ai\Embedder, App\Ai\ChatClient) rather than injecting the raw platform into controllers — you'll want to fake it in tests, and you'll want one place to enforce timeouts, caching, and logging.
Where to look first: the Task.php and Provider.php interfaces in the package (they're the actual API surface — the README only lists examples), then the HuggingFace Tasks reference linked in the README.
First use case: embeddings, not chat. It's the lowest-risk, highest-ROI entry point and doesn't depend on prompt quality:
$result = $platform->invoke('sentence-transformers/all-MiniLM-L6-v2', 'Hello world', [
'task' => Task::FEATURE_EXTRACTION,
]);
$vectors = $result->asVectors();
Prove that out, store the vectors, then move to chat completion once you trust the auth and quota story.
Chat: build the MessageBag in an app-level service, not the controller.
$messages = new MessageBag(Message::ofUser($prompt));
$result = $platform->invoke('HuggingFaceH4/zephyr-7b-beta', $messages, [
'task' => Task::CHAT_COMPLETION,
'temperature' => 0.7,
'max_new_tokens'=> 256,
]);
return $result->asText();
Keep the model id in config/services.php, not inline — you will swap it after model discovery.
Per-request provider routing is the killer feature for day-to-day cost/latency control. Set a sane default in config, override at the call site:
$result = $platform->invoke($model, $messages, [
'task' => Task::CHAT_COMPLETION,
'provider' => Provider::GROQ, // fast path for interactive UI
]);
Pattern that works well: interactive/request-cycle calls → Groq; batch/queued jobs → HuggingFace inference (cheaper, cold starts don't matter); niche tasks (audio, segmentation) → whatever provider actually hosts the model. Put that mapping in a config array keyed by task, and resolve it in your service.
Push slow tasks onto the queue. Audio transcription, image segmentation, and anything with a cold start will blow past PHP-FPM's timeout. Wrap the invoke in a job, store results, broadcast on completion via an event. Treat every non-chat task as async by default.
Multimodal input. Image::fromFile() wants a local path. With Storage::disk('s3') you get bytes, not paths — write to tempnam() / a storage/app/tmp file in the job, invoke, then unlink in a finally. Don't leak temp files on exception.
Result handling is task-specific. asText(), asObject(), asVectors() are not interchangeable:
$result = $platform->invoke('google/vit-base-patch16-224', $image, [
'task' => Task::IMAGE_CLASSIFICATION,
]);
foreach ($result->asObject()->getClassifications() as $c) {
$label = $c->getLabel();
$score = $c->getScore();
}
Normalize into your own DTOs immediately — never let a getClassifications() object escape into a Blade view or an API resource.
Model discovery before you code. The two commands don't burn inference credits, which makes them a legitimate part of your workflow:
ai:huggingface:model-list --task=text-generation --warm
ai:huggingface:model-list --provider=hf-inference --search=llama
ai:huggingface:model-info meta-llama/Llama-2-7b-chat
They're Symfony Console commands, so register them in AiServiceProvider::boot() via $this->commands([...]) (Laravel resolves Symfony commands fine) or run them via a one-off php artisan tinker invocation. Verify each one registers cleanly — they may expect Symfony's console framework conventions.
Integration tip — cache by input hash. Wrap embeddings in Cache::remember('emb:' . $model . ':' . sha1($text), ...). Embeddings are deterministic for a given model, and you'll re-run seeders and tests far more often than you expect.
This is a 0.x package with 4 stars and 0 dependents. Pin it ("^0.14") and read the changelog before every bump. Real API breaks already happened in the 0.x line: the Provider abstraction landed in v0.8.0, base URL handling was reworked in v0.11.0, and v0.10.0 shipped a fix for malformed UTF-8 in request bodies. If you pin below 0.10, sanitize user input yourself before sending.
HttpClientInterface is non-negotiable. Don't try to pass Laravel's HTTP client. If you want retries/logging, use Symfony HttpClient's retry_failed and middleware — that's the natural extension point, and it's where you should handle 429s and 503 "model loading" responses with exponential backoff.
Cold starts are the #1 production surprise. A model that's not warm can take 10–60s or return a 503. Mitigations: filter models with --warm during discovery, keep a known-good warm model as fallback, and never call a cold model synchronously in a web request.
task must match the model. Sending Task::CHAT_COMPLETION to an embeddings model yields an opaque 400/404 from the provider. Validate the (model, task) pair in your config and fail fast at boot with a clear message rather than at runtime.
Model ids contain slashes (meta-llama/Llama-2-7b-chat). Fine in config arrays, broken in route segments or cache keys without hashing/encoding. Use str_replace('/', '--', $model) for keys.
Result object shape varies per task. asObject() returns different result classes depending on the task; asVectors() may be a nested array. Don't write one generic "read the result" helper — map each task you actually use.
Don't mix embedding models in one table. Dimensions differ between models (all-MiniLM-L6-v2 = 384, others 768/1024). Version your vector column alongside a model column, or you'll get silent garbage in similarity search when you swap models.
Secrets and scopes. HuggingFace tokens are read/write-scoped — issue a read-only token for inference and keep it in .env/SSM, never in config/ committed files. Rotate before you go public; the bridge has no way to redact it from logs you write around it.
Extension point worth knowing: since Provider and Task are interfaces of constants, string literals generally work where a constant isn't defined yet — you're not blocked waiting for a package release when a new provider or task ships upstream. Just add the constant to your own config map and keep a note to migrate.
Debugging recipe: log the model id, task, provider, and the resolved base URL on every failure; nine out of ten "it broke" reports are a provider/model mismatch or an expired token. Then reproduce with ai:huggingface:model-info before touching code.
How can I help you explore Laravel packages today?