raulast/macro-llm-php

Provider-agnostic AI client for Laravel, Slim 4, and standalone PHP. Extends Laravel's HTTP client via macros with support for OpenAI, Anthropic, Gemini, Groq, OpenRouter, Ollama, and llama.cpp. Includes tool calling, skills, agents, multi-agent orchestration, and MCP server/client.

Maintainers

Package info

github.com/raulast/macro-llm-php

pkg:composer/raulast/macro-llm-php

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-06-23 05:34 UTC

This package is auto-updated.

Last update: 2026-06-23 07:21:16 UTC


README

Provider-agnostic AI client for Laravel, Slim 4, and standalone PHP.

PHP Version Laravel License Packagist

Overview

MacroLLM extends Laravel's HTTP client (PendingRequest) through PHP macros, giving you a unified interface to interact with any AI provider. Call Http::openai(...), Http::anthropic(...), or Http::gemini(...) with the same request format — swap providers by changing a single string.

Internally, every request and response flows through a normalized format (InternalRequest / InternalResponse). Providers implement bidirectional normalization: your application code stays the same regardless of which provider is behind the call. This decouples business logic from vendor-specific APIs and makes provider migration a configuration change, not a rewrite.

On top of the provider layer, MacroLLM provides a full agentic stack: Skills (reusable system-prompt + tool bundles), Agents (automatic tool-call loops with configurable memory), and Orchestration (sequential or parallel multi-agent workflows). Combined with built-in MCP client/server support, you can build complex AI-powered systems while keeping each piece testable and swappable.

Features

  • 9 built-in providers: OpenAI, Anthropic, Gemini, Groq, OpenRouter, Ollama, llama.cpp, OpenCode Zen Go, OpenCode Zen Go (Anthropic)
  • Unified InternalRequest / InternalResponse format with bidirectional normalization
  • Automatic tool-call loop via Agent
  • Reusable Skills (system prompt + tools + config — composable, subclassable, DB-hydratable via fromArray)
  • Parametrizable conversation memory (NullMemory stateless default, InMemoryMemory, extensible)
  • Multi-agent Orchestration (sequential and parallel via Guzzle concurrent requests)
  • MCP Client — discover and use tools from any MCP server
  • MCP Server — expose your tools as a PSR-15 middleware endpoint
  • Laravel integration (ServiceProvider, Facade, auto-discovery, vendor:publish)
  • Slim 4 integration (MacroLLMSlimExtension)
  • Standalone PHP — no framework required
  • PHP 8.1+ (enums, readonly classes, fibers)

Supported Providers

Provider Type Auth Default Base URL Notes
openai OpenAI-compatible Bearer API key api.openai.com/v1 Also supports Azure OpenAI via base_url override
anthropic Native x-api-key header api.anthropic.com/v1 Messages API
gemini Native x-goog-api-key header generativelanguage.googleapis.com/v1beta generateContent API
groq OpenAI-compatible Bearer API key api.groq.com/openai/v1
openrouter OpenAI-compatible Bearer API key openrouter.ai/api/v1 Model names prefixed: openai/gpt-4o
ollama OpenAI-compatible Optional localhost:11434/v1 Local inference
llamacpp OpenAI-compatible None localhost:8080/v1 Local inference
opencode-zen-go OpenAI-compatible Bearer API key opencode.ai GLM, Kimi, DeepSeek, MiMo; API key from opencode.ai Zen console
opencode-zen-go-anthropic Anthropic-compatible x-api-key header opencode.ai MiniMax, Qwen; same API key as opencode-zen-go

Requirements

  • PHP ^8.1
  • Laravel ^10 | ^11 (optional, for Laravel integration)

Installation

composer require raulast/macro-llm-php

Laravel auto-discovery registers the ServiceProvider automatically. Optionally publish the config:

php artisan vendor:publish --tag=macro-llm-config

Configuration

The config file (config/macro-llm.php) defines:

Key Description Default
default_provider Provider used when none is specified ollama
timeout Global request timeout in seconds 30
retries Automatic retries on failure 0
max_tool_iterations Max agent tool-call loop iterations 10
providers Array of provider configurations
mcp_servers External MCP server connections

Each provider entry supports: api_key, default_model, base_url, timeout, retries, extra_headers.

API keys support environment variable patterns ('${ENV_VAR}') resolved lazily at access time.

Example .env entries:

MACRO_LLM_DEFAULT_PROVIDER=openai
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=AIza...
GROQ_API_KEY=gsk_...
OPENROUTER_API_KEY=sk-or-...
OPENCODE_ZEN_API_KEY=ocz-...

Usage

Standalone (no framework)

use MacroLLM\MacroLLM;
use MacroLLM\Config\Config;
use MacroLLM\Message\InternalRequest;
use MacroLLM\Message\InternalMessage;

$llm = MacroLLM::standalone(Config::fromArray([
    'default_provider' => 'openai',
    'providers' => [
        'openai' => ['api_key' => '${OPENAI_API_KEY}', 'default_model' => 'gpt-4o'],
    ],
]));

$response = $llm->chat(new InternalRequest([
    InternalMessage::user('Hello, world!'),
]));

echo $response->content;

Laravel (via Facade)

use MacroLLM\Integration\Laravel\MacroLLMFacade as LLM;
use MacroLLM\Message\InternalRequest;
use MacroLLM\Message\InternalMessage;

$response = LLM::chat(new InternalRequest([
    InternalMessage::system('You are a helpful assistant.'),
    InternalMessage::user('What is Laravel?'),
]));

Laravel (via HTTP macro)

use MacroLLM\Message\InternalRequest;
use MacroLLM\Message\InternalMessage;

$response = Http::openai(new InternalRequest([
    InternalMessage::user('Explain macros in PHP.'),
]));

Streaming

foreach ($llm->stream(new InternalRequest([InternalMessage::user('Tell me a story.')])) as $chunk) {
    if ($chunk->finished) {
        echo "\n\n[done — {$chunk->response->usage->totalTokens} tokens]\n";
        break;
    }
    echo $chunk->delta;
    flush();
}

Listing Available Models

// Fetches live model list from the provider's own API (may make HTTP request)
$models = $llm->models('openai');
// → ['gpt-4o', 'gpt-4o-mini', 'o1', 'o3', ...]

$models = $llm->models('anthropic');
// → ['claude-opus-4-5', 'claude-sonnet-4-5', ...]

// OpenCode Zen fetches from GET /zen/go/v1/models
$models = $llm->models('opencode-zen-go');
// → ['deepseek-v3-0324', 'glm-z1-flash', 'kimi-k2', ...]

// Ollama and llama.cpp reflect locally installed / loaded models
$models = $llm->models('ollama');
// → ['llama3.2:latest', 'codellama:7b', ...]

// Via the Facade (Laravel)
$models = \MacroLLM\Integration\Laravel\MacroLLMFacade::models('gemini');

// Via provider directly (no HTTP needed for provider object access)
$provider = $llm->providers()->get('groq');
$models = $provider->getModels();

Tool Calling

use MacroLLM\Tool\ToolDefinition;

$llm->tools()->register(new ToolDefinition(
    name: 'get_weather',
    description: 'Get the current weather for a city.',
    parameters: [
        'type' => 'object',
        'properties' => [
            'city' => ['type' => 'string', 'description' => 'City name'],
        ],
        'required' => ['city'],
    ],
    callable: fn(array $args): string => "Sunny, 22°C in {$args['city']}",
));

$agent = $llm->agent(new \MacroLLM\Agent\AgentConfig(
    provider: 'openai',
    maxIterations: 5,
));

$response = $agent->run('What is the weather in Buenos Aires?');
echo $response->content;

Skills

use MacroLLM\Skill\Skill;

// Inline skill
$translatorSkill = Skill::create(
    name: 'translator',
    systemPrompt: 'You are a professional translator. Always respond in the target language.',
    tools: ['detect_language', 'translate_text'],
);

$llm->skills()->register($translatorSkill);

// From DB record
$skillData = $db->find('skills', 1);
$skill = Skill::fromArray($skillData);
$llm->skills()->register($skill);

// Dynamic skill via subclass
class SupportSkill extends Skill
{
    public function __construct(private string $product) {}
    public function getName(): string { return 'support'; }
    public function getSystemPrompt(): string {
        return "You are a support agent for {$this->product}. Be helpful and concise.";
    }
}

Agent Step Callback

Observe each event in the agent's tool-call loop without modifying or extending Agent (which is final). Pass an onStep closure to AgentConfig — it receives an AgentStep value object at every meaningful point in the loop.

use MacroLLM\Agent\AgentConfig;
use MacroLLM\Agent\AgentStep;
use MacroLLM\Agent\AgentStepType;

$agent = $llm->agent(new AgentConfig(
    provider: 'openai',
    maxIterations: 10,
    onStep: function (AgentStep $step): void {
        echo match ($step->type) {
            AgentStepType::LlmResponse    => "[{$step->iteration}] LLM responded with tool calls\n",
            AgentStepType::ToolCall       => "[{$step->iteration}] → calling {$step->toolCall->name}\n",
            AgentStepType::ToolResult     => "[{$step->iteration}] ← result: " . json_encode($step->toolResult->content) . "\n",
            AgentStepType::FinalResponse  => "[{$step->iteration}] Final: {$step->response->content}\n",
        };
    },
));

$response = $agent->run('What is the weather in Buenos Aires?');

Step types:

Type Fires when Populated fields
LlmResponse LLM responds with tool calls (loop continues) response
ToolCall Before a tool is executed toolCall
ToolResult After a tool finishes (success or error) toolCall, toolResult
FinalResponse LLM responds with no tool calls (loop exits) response

The callback is fire-and-forget: exceptions propagate to the run() caller. Passing null (the default) has zero overhead on the hot path.

Conversation Memory

use MacroLLM\Agent\AgentConfig;
use MacroLLM\Agent\Memory\InMemoryMemory;

$agent = $llm->agent(new AgentConfig(
    provider: 'anthropic',
    memory: new InMemoryMemory(), // stateful — remembers across run() calls
));

$agent->run('My name is Ana.');
$response = $agent->run('What is my name?');
echo $response->content; // "Your name is Ana."

Multi-Agent Orchestration

use MacroLLM\Orchestration\Orchestrator;
use MacroLLM\Orchestration\RoutingStrategy;
use MacroLLM\Orchestration\ErrorStrategy;

$orchestrator = new Orchestrator(
    routing: RoutingStrategy::Parallel,
    errorStrategy: ErrorStrategy::Continue,
);

$orchestrator->addAgent('researcher', $llm->agent(new AgentConfig(
    provider: 'openai',
    systemPrompt: 'Research and summarize information.',
)));

$orchestrator->addAgent('writer', $llm->agent(new AgentConfig(
    provider: 'anthropic',
    systemPrompt: 'Write engaging content based on research.',
)));

$result = $orchestrator->dispatch('Write an article about PHP 8.1 fibers.');

foreach ($result->outcomes as $outcome) {
    echo "Agent {$outcome->agentName} ({$outcome->durationMs}ms):\n";
    echo $outcome->response?->content . "\n\n";
}

MCP Client

use MacroLLM\Mcp\MCPClient;

$mcp = new MCPClient($llm->tools());
$mcp->connect('filesystem', 'http://localhost:3001', auth: 'my-token');
// Tools now available as "filesystem/read_file", "filesystem/write_file", etc.

$agent = $llm->agent(new AgentConfig(provider: 'openai'));
$response = $agent->run('Read the contents of /tmp/notes.txt');

MCP Server (Laravel)

// In a Laravel route or controller:
use MacroLLM\Mcp\MCPServer;
use MacroLLM\Mcp\MCPServerMiddleware;

// Mount as PSR-15 middleware on /mcp
$app->middleware(MCPServerMiddleware::class);
// External MCP clients can now call tools/list and tools/call on /mcp

Slim 4

use MacroLLM\Integration\Slim\MacroLLMSlimExtension;
use MacroLLM\Message\InternalRequest;
use MacroLLM\Message\InternalMessage;
use Slim\Factory\AppFactory;
use DI\Container;

// PHP-DI container (composer require php-di/php-di)
$container = new Container();
AppFactory::setContainer($container);
$app = AppFactory::create();

// Register MacroLLM into the container
$extension = new MacroLLMSlimExtension($container, require __DIR__ . '/config/macro-llm.php');
$extension->register();

// Use MacroLLM inside a route
$app->get('/chat', function ($request, $response) use ($container) {
    $llm    = $container->get(\MacroLLM\MacroLLM::class);
    $result = $llm->chat(new InternalRequest([
        InternalMessage::user('Hello!'),
    ]));
    $response->getBody()->write($result->content ?? '');
    return $response;
});

$app->run();

MacroLLMSlimExtension requires the container to support set(). PHP-DI's Container does. Slim's built-in container does not — use PHP-DI or another writable PSR-11 container.

The config/macro-llm.php file lives in your project (not in the package) and returns a plain array:

// your-project/config/macro-llm.php
return [
    'default_provider' => 'ollama',
    'providers' => [
        'ollama' => [
            'api_key'       => getenv('OLLAMA_API_KEY') ?: 'local',
            'default_model' => 'llama3.2',
            'base_url'      => 'http://localhost:11434/v1',
        ],
    ],
];

Same format as Config::fromArray() — see the Configuration section.

Directory Structure

src/
├── Agent/          # Agent loop, AgentConfig, Memory strategies
├── Config/         # Config and ProviderConfig value objects
├── Contract/       # Interfaces (Provider, Skill, Memory, Concurrency)
├── Exception/      # Domain-specific exceptions
├── Integration/    # Framework bindings (Laravel, Slim)
├── Mcp/            # MCP Client, Server, and PSR-15 middleware
├── Message/        # InternalRequest, InternalResponse, StreamChunk, Usage
├── Orchestration/  # Multi-agent orchestrator, strategies, results
├── Provider/       # Provider implementations and factory
├── Registry/       # Tool, Skill, and Provider registries
├── Skill/          # Base Skill class (composable, subclassable)
├── Tool/           # ToolDefinition, ToolCall, ToolResult
├── skill-macro-llm-php/  # AI agent skill document (SKILL.md for coding assistants)
└── MacroLLM.php    # Main entry point and macro registration

Architecture

MacroLLM follows a hexagonal architecture. The core domain (Message, Contract, Registry) has no framework dependencies. The provider layer implements bidirectional normalization behind ProviderInterface, so adding a new provider means implementing a single class without touching application code. The agentic layer (Agent, Skill, Orchestration) composes on top of the provider layer, using the same normalized types. Framework integrations (Laravel, Slim) are thin adapters that wire the core into their respective DI containers and lifecycle hooks.

Exception Handling

Exception Thrown when
UnregisteredProviderException Macro called for unregistered provider
ProviderRequestException HTTP 4xx/5xx from provider API
MissingApiKeyException API key missing before request
ToolNotFoundException Model requested an unregistered tool
MaxToolIterationsException Agent loop exceeded max iterations
SkillToolConflictException Two composed skills define same tool
SkillToolNotFoundException Skill references a tool not in the registry
MCPConnectionException MCP server unreachable
MCPToolCallException MCP server returned error
StreamInterruptedException SSE stream ended unexpectedly

License

MIT — see LICENSE file.

Author

Raul Antonio Salazar Torres — raulast.dev@gmail.com