A step‑by‑step guide to designing, coding, testing, documenting, and releasing a Laravel package that talks to a local Ollama server.
Why Ollama + Laravel?
Ollama makes it dead‑simple to run large language models (LLMs) locally. Laravel gives you an expressive toolkit for building PHP applications. A dedicated package is the cleanest way to:
- Centralize HTTP calls to the Ollama API (chat, generate, embeddings, models, etc.).
- Offer a fluent, framework‑native API via Facades and dependency injection.
- Provide config, caching, logging, and test fakes out of the box.
This article uses camh/laravel-ollama as the concrete example, but the structure applies to any Laravel package talking to Ollama.
Prerequisites
- PHP 8.2+ and Composer
- Laravel 10 or 11
- Ollama installed locally and running (default:
http://localhost:11434) - Basic familiarity with Laravel packages (service providers, facades, config)
Package Goals
We’ll build a package that:
- Wraps Ollama endpoints with a typed, ergonomic client.
- Supports both non‑streaming and streaming responses.
- Exposes a Facade (
Ollama) and injectable interfaces. - Adds config + environment variables for base URL, timeouts, and model defaults.
- Includes testing utilities (Http fakes and example fixtures).
- Ships with documentation and CI.
Project Skeleton
laravel-ollama/ ├─ src/ │ ├─ Contracts/ │ │ └─ OllamaClient.php │ ├─ DTOs/ │ │ ├─ ChatMessage.php │ │ ├─ ChatResponse.php │ │ └─ EmbeddingResponse.php │ ├─ Http/ │ │ └─ Client.php │ ├─ Facades/ │ │ └─ Ollama.php │ ├─ OllamaServiceProvider.php │ └─ Support/StreamIterator.php ├─ config/ollama.php ├─ tests/ │ ├─ Feature/ │ └─ Unit/ ├─ composer.json ├─ README.md └─ CHANGELOG.md Composer Setup
composer.json
{ "name": "camh/laravel-ollama", "description": "Laravel wrapper for the Ollama local LLM API (chat, generate, embeddings).", "type": "library", "license": "MIT", "require": { "php": ">=8.2", "illuminate/support": "^10.0|^11.0" }, "autoload": { "psr-4": { "CamH\\LaravelOllama\\": "src/" } }, "extra": { "laravel": { "providers": [ "CamH\\LaravelOllama\\OllamaServiceProvider" ], "aliases": { "Ollama": "CamH\\LaravelOllama\\Facades\\Ollama" } } }, "minimum-stability": "stable", "prefer-stable": true } Configuration
config/ollama.php
<?php return [ 'base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'), // default model to use if not provided explicitly 'model' => env('OLLAMA_MODEL', 'llama3.1:8b'), // timeouts (in seconds) 'timeout' => env('OLLAMA_TIMEOUT', 120), 'connect_timeout' => env('OLLAMA_CONNECT_TIMEOUT', 5), ]; .env
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_MODEL=llama3.1:8b OLLAMA_TIMEOUT=120 OLLAMA_CONNECT_TIMEOUT=5 Add a publish group so users can copy the config into their app:
// in OllamaServiceProvider::boot() $this->publishes([ __DIR__.'/../config/ollama.php' => config_path('ollama.php'), ], 'ollama-config'); Service Provider & Container Bindings
src/OllamaServiceProvider.php
<?php namespace CamH\LaravelOllama; use CamH\LaravelOllama\Contracts\OllamaClient as OllamaClientContract; use CamH\LaravelOllama\Http\Client as HttpClient; use Illuminate\Support\ServiceProvider; class OllamaServiceProvider extends ServiceProvider { public function register(): void { $this->mergeConfigFrom(__DIR__.'/../config/ollama.php', 'ollama'); $this->app->bind(OllamaClientContract::class, function ($app) { $config = $app['config']['ollama']; return new HttpClient( baseUrl: $config['base_url'], defaultModel: $config['model'], timeout: (int) $config['timeout'], connectTimeout: (int) $config['connect_timeout'], ); }); } public function boot(): void { $this->publishes([ __DIR__.'/../config/ollama.php' => config_path('ollama.php'), ], 'ollama-config'); } } Contracts (Interface‑first Design)
src/Contracts/OllamaClient.php
<?php namespace CamH\LaravelOllama\Contracts; use Generator; interface OllamaClient { /** Simple one‑shot completion (non‑streaming). */ public function generate(string $prompt, ?string $model = null, array $options = []): string; /** Chat with role‑based messages (non‑streaming). */ public function chat(array $messages, ?string $model = null, array $options = []): array; /** Token‑streaming chat. Yields partial text chunks as they arrive. */ public function streamChat(array $messages, ?string $model = null, array $options = []): Generator; /** Create embeddings for given input text(s). */ public function embeddings(string|array $input, ?string $model = null, array $options = []): array; /** List available local models. */ public function models(): array; } The HTTP Client
We’ll use Laravel’s HTTP client (Illuminate\Support\Facades\Http) behind a thin adapter.
src/Http/Client.php
<?php namespace CamH\LaravelOllama\Http; use CamH\LaravelOllama\Contracts\OllamaClient; use Generator; use Illuminate\Support\Facades\Http; class Client implements OllamaClient { public function __construct( private readonly string $baseUrl, private readonly string $defaultModel, private readonly int $timeout = 120, private readonly int $connectTimeout = 5, ) {} protected function http() { return Http::baseUrl($this->baseUrl) ->timeout($this->timeout) ->connectTimeout($this->connectTimeout) ->acceptJson(); } public function generate(string $prompt, ?string $model = null, array $options = []): string { $payload = array_merge([ 'model' => $model ?? $this->defaultModel, 'prompt' => $prompt, 'stream' => false, ], $options); $response = $this->http()->post('/api/generate', $payload)->throw(); // Ollama returns { response: "...", ... } return (string) $response->json('response', ''); } public function chat(array $messages, ?string $model = null, array $options = []): array { $payload = array_merge([ 'model' => $model ?? $this->defaultModel, 'messages' => $messages, 'stream' => false, ], $options); $response = $this->http()->post('/api/chat', $payload)->throw(); return $response->json(); } public function streamChat(array $messages, ?string $model = null, array $options = []): Generator { $payload = array_merge([ 'model' => $model ?? $this->defaultModel, 'messages' => $messages, 'stream' => true, ], $options); $response = $this->http()->withOptions(['stream' => true])->post('/api/chat', $payload)->throw(); foreach ($response->toPsrResponse()->getBody() as $chunk) { $line = trim((string) $chunk); if ($line === '') continue; $json = json_decode($line, true); if (isset($json['message']['content'])) { yield $json['message']['content']; } } } public function embeddings(string|array $input, ?string $model = null, array $options = []): array { $payload = array_merge([ 'model' => $model ?? $this->defaultModel, 'input' => $input, ], $options); $response = $this->http()->post('/api/embeddings', $payload)->throw(); return $response->json(); } public function models(): array { return $this->http()->get('/api/tags')->throw()->json(); } } Note: Ollama’s streaming endpoints send a stream of JSON lines. We iterate the PSR stream and decode each line.
Facade for Ergonomics
src/Facades/Ollama.php
<?php namespace CamH\LaravelOllama\Facades; use CamH\LaravelOllama\Contracts\OllamaClient as OllamaClientContract; use Illuminate\Support\Facades\Facade; /** @method static string generate(string $prompt, ?string $model = null, array $options = []) * @method static array chat(array $messages, ?string $model = null, array $options = []) * @method static \Generator streamChat(array $messages, ?string $model = null, array $options = []) * @method static array embeddings(string|array $input, ?string $model = null, array $options = []) * @method static array models() */ class Ollama extends Facade { protected static function getFacadeAccessor() { return OllamaClientContract::class; } } Usage in a Laravel App
Install
composer require camh/laravel-ollama php artisan vendor:publish --tag=ollama-config Generate text (controller or job):
use CamH\LaravelOllama\Facades\Ollama; $text = Ollama::generate('Write a haiku about monsoons.', model: 'llama3.1:8b'); Chat
$reply = Ollama::chat([ ['role' => 'system', 'content' => 'You are a concise assistant.'], ['role' => 'user', 'content' => 'Summarize Laravel in 1 sentence.'], ]); $assistant = data_get($reply, 'message.content'); Streaming chat (controller returning an SSE stream)
use Symfony\Component\HttpFoundation\StreamedResponse; use CamH\LaravelOllama\Facades\Ollama; return new StreamedResponse(function () { $messages = [ ['role' => 'user', 'content' => 'Explain queues in Laravel concisely.'] ]; foreach (Ollama::streamChat($messages) as $delta) { echo "data: ".$delta."\n\n"; ob_flush(); flush(); } }, 200, [ 'Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'X-Accel-Buffering' => 'no', ]); Embeddings
$response = Ollama::embeddings([ 'Laravel is a delightful PHP framework.', 'Eloquent provides ActiveRecord-like models.' ]); $firstVector = $response['embeddings'][0] ?? []; List models
$models = Ollama::models(); Error Handling & Timeouts
- Use
$response->throw()so HTTP ≥ 400 raises exceptions. - Catch and convert
RequestExceptionto domain‑specific exceptions if you want (OllamaUnavailable,OllamaValidationError). - Let users override
timeoutandconnect_timeoutvia config andoptionsarguments.
Example domain exceptions:
class OllamaUnavailable extends \RuntimeException {} class OllamaValidationError extends \InvalidArgumentException {} Testing Strategy
- Unit tests for the client using
Http::fake()to simulate Ollama responses. - Feature tests for your routes / controllers consuming the facade.
- Provide fixtures (JSON lines for streaming) to test parsers.
Example
use Illuminate\Support\Facades\Http; use CamH\LaravelOllama\Http\Client; it('generates text', function () { Http::fake([ 'http://localhost:11434/api/generate' => Http::response([ 'response' => 'Hello world', ], 200), ]); $client = new Client('http://localhost:11434', 'llama3.1:8b'); $text = $client->generate('Say hello'); expect($text)->toBe('Hello world'); }); Streaming test helper (fake JSONL):
$stream = "{\"message\":{\"content\":\"Hel\"}}\n{\"message\":{\"content\":\"lo\"}}\n"; Http::fake([ '*' => Http::response($stream, 200, ['Content-Type' => 'application/x-ndjson']) ]); Documentation & DX
- Ship a README with install, configuration, and quickstart examples.
- Add PHPDoc on public methods and return types.
- Provide copy‑paste examples for SSE streaming and queue jobs.
- Include a
php artisanexample command to prove the integration end‑to‑end.
Example command
// app/Console/Commands/OllamaAsk.php protected $signature = 'ollama:ask {prompt} {--model=}'; public function handle(): int { $model = $this->option('model'); $answer = \CamH\LaravelOllama\Facades\Ollama::generate($this->argument('prompt'), $model); $this->line($answer); return self::SUCCESS; } Releasing to Packagist
- Create a public Git repository.
- Ensure
composer.jsonhas correct name, autoload, and extra.laravel. - Tag a release:
git tag v1.0.0 && git push --tags. - Submit the repo to packagist.org once (future tags auto‑sync).
Versioning tips
- Follow SemVer.
- Maintain a CHANGELOG.md.
- Use GitHub Actions to run tests on PHP 8.2/8.3 and Laravel 10/11.
Example CI (GitHub Actions)
name: tests on: [push, pull_request] jobs: phpunit: runs-on: ubuntu-latest strategy: matrix: php: ['8.2', '8.3'] laravel: ['10.*', '11.*'] steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} tools: composer:v2 - run: composer require "illuminate/support:${{ matrix.laravel }}" --no-interaction --no-progress --no-suggest - run: composer install --no-interaction --prefer-dist - run: vendor/bin/phpunit Security, Performance & Ops Notes
- Never trust prompts; validate and size‑limit user input.
- Consider rate limiting endpoints that proxy to Ollama.
- Add caching for
models()(e.g., cache for 5–10 minutes) to avoid frequent calls. - Support retry/backoff on transient errors.
- Log latency and token usage (if available) for observability.
$models = cache()->remember('ollama:models', 600, fn () => Ollama::models()); Advanced: Middleware & Pipelines
- Add middleware to inject system prompts, sanitize user input, or enforce max tokens.
- Provide a pipeline API for RAG: retrieve docs → build context → call
chat(). - Consider stream transformers to emit SSE, console updates, or WebSockets.
Troubleshooting
- Connection refused → Verify Ollama is running and
OLLAMA_BASE_URLis correct. - Model not found → Pull the model:
ollama pull llama3.1:8b. - Timeouts → Increase
OLLAMA_TIMEOUTor simplify prompts. - Binary size → Exclude tests/fixtures from export via
.gitattributes.
Example End‑to‑End Controller
namespace App\Http\Controllers; use CamH\LaravelOllama\Facades\Ollama; use Illuminate\Http\Request; class AskController { public function __invoke(Request $request) { $validated = $request->validate([ 'prompt' => ['required','string','max:4000'], 'model' => ['nullable','string'] ]); $answer = Ollama::generate($validated['prompt'], $validated['model'] ?? null); return response()->json([ 'answer' => $answer, ]); } } Conclusion
The camh/laravel-ollama package is only the first step. The current focus has been on wrapping Ollama’s core APIs and providing a clean Laravel interface, but there is a clear roadmap ahead:
- Tool & function calling: add helpers for structured outputs and integrations with external services.
- Conversation memory: support multi‑turn conversation stores (database, cache, or Redis) to persist chat history.
- RAG workflows: deeper integration with Laravel Scout or custom pipelines to enable retrieval‑augmented generation.
- Monitoring & Observability: built‑in hooks for logging, metrics, and tracing of requests.
- Community feedback: open issues and PRs will shape features for better developer experience.
In an upcoming article, we will move beyond package design and demonstrate how to use camh/laravel-ollama in a real Laravel project. That walkthrough will cover building controllers, jobs, and even front‑end integrations that take advantage of local LLMs through this package.
Stay tuned—the best part of Laravel + Ollama is seeing it power actual products and workflows!

Top comments (0)