Search by

blaspsoft / forerunner

Deemonic

A Laravel package that provides an elegant, migration-inspired API for defining JSON schemas that ensure your LLM responses are perfectly structured every time.

Package info

github.com/Blaspsoft/forerunner

pkg:composer/blaspsoft/forerunner

Statistics

Installs: 2 632

Dependents: 0

Suggesters: 0

Stars: 54

Open Issues: 1

v0.4.1 2026-11-03 12:12 UTC

This package is auto-updated.

Last update: 2026-11-03 12:14:22 UTC


README

Forerunner Logo

Tests Total Downloads Latest Version on Packagist License CodeRabbit Pull Request Reviews

Forerunner - Build structured LLM outputs the Laravel way

A Laravel package that provides an elegant, migration-inspired API for defining JSON schemas that ensure your LLM responses are perfectly structured every time.

Installation

You can install the package via composer:

composer require blaspsoft/forerunner:^0.4

Note: This is a pre-release version (0.x). The API may change as we gather feedback and iterate towards 1.0.0.

The package will automatically register its service provider.

Upgrading from 0.1.x

If you're upgrading from version 0.1.x, please note there are breaking changes. See the v0.2.0 release notes for a complete migration guide.

Quick Start

Using the Artisan Command

Generate a new structure class:

php artisan make:struct UserProfile

This creates a structure class at app/Structures/UserProfile.php:

<?php

namespace App\Structures;

use Blaspsoft\Forerunner\Schema\Struct;
use Blaspsoft\Forerunner\Schema\Property;

class UserProfile
{
    public static function schema(): Struct
    {
        return Struct::define('user_profile', 'Description of user_profile', function (Property $property) {
            $property->string('example_field');
            // Add your fields here

            $property->strict(); // All fields required + no additional properties
        });
    }
}

Basic Usage

Define a schema using the Struct class or Schema facade:

use Blaspsoft\Forerunner\Schema\Struct;
use Blaspsoft\Forerunner\Schema\Property;

$schema = Struct::define('User', 'A user schema', function (Property $property) {
    $property->string('name', 'The user\'s full name')->required();
    $property->string('email', 'The user\'s email address')->required();
    $property->int('age', 'The user\'s age')->min(0)->max(150);
    $property->boolean('is_active', 'Is the user account active?')->default(true);
})->toArray();

Or using the facade for a cleaner syntax:

use Blaspsoft\Forerunner\Facades\Schema;
use Blaspsoft\Forerunner\Schema\Property;

$schema = Schema::define('User', 'A user schema', function (Property $property) {
    $property->string('name', 'The user\'s full name')->required();
    $property->string('email', 'The user\'s email address')->required();
})->toArray();

Available Field Types

String Fields

$property->string('username', 'The username')
    ->minLength(3)
    ->maxLength(50)
    ->pattern('^[a-zA-Z0-9_]+$')
    ->required();

Integer Fields

$property->int('age', 'User age')
    ->min(0)
    ->max(150)
    ->default(18);

// Alias
$property->integer('count');

Float/Number Fields

$property->float('price', 'Product price')
    ->min(0.0)
    ->max(9999.99);

// Alias
$property->number('rating')->min(0)->max(5);

Boolean Fields

$property->boolean('is_active', 'Account status')
    ->default(true);

// Alias
$property->bool('verified');

Array Fields

// Simple array
$property->array('tags', 'User tags')
    ->items('string')
    ->minItems(1)
    ->maxItems(10);

// Array of objects
$property->array('addresses')->items('object', function (Property $item) {
    $item->string('street')->required();
    $item->string('city')->required();
    $item->string('zip')->required();
});

Enum Fields

$property->enum('role', ['admin', 'user', 'guest'], 'User role')
    ->default('user');

$property->enum('status', ['draft', 'published', 'archived']);

Object Fields

$property->object('address', function (Property $nested) {
    $nested->string('street', 'Street address')->required();
    $nested->string('city', 'City name')->required();
    $nested->string('zip', 'ZIP code')->required();
    $nested->object('coordinates', function (Property $coords) {
        $coords->float('latitude')->required();
        $coords->float('longitude')->required();
    });
}, 'User address');

Field Constraints

String Constraints

$property->string('username')
    ->minLength(3)              // Minimum length
    ->maxLength(50)             // Maximum length
    ->pattern('^[a-zA-Z0-9]+$') // Regex pattern
    ->required();               // Mark as required

Numeric Constraints

$property->int('age')
    ->min(0)          // Minimum value
    ->max(150)        // Maximum value
    ->default(18);    // Default value

Array Constraints

$property->array('tags')
    ->items('string')  // Type of array items
    ->minItems(1)      // Minimum array length
    ->maxItems(10);    // Maximum array length

General Constraints

$property->string('field')
    ->required()                    // Mark as required
    ->optional()                    // Mark as optional (default)
    ->default('value')              // Set default value
    ->description('Field description'); // Add description

Advanced Features

Helper Methods for Common Formats

Forerunner provides convenient helper methods for commonly used field formats:

// Email field with automatic format validation
$property->email('email')->required();

// URL field
$property->url('website');

// UUID field
$property->uuid('id')->required();

// Date-time field (ISO 8601)
$property->datetime('created_at');

// Date field
$property->date('birth_date');

// Time field
$property->time('start_time');

// IPv4 address
$property->ipv4('ip_address');

// IPv6 address
$property->ipv6('ipv6_address');

// Hostname
$property->hostname('server_name');

String Format Validation

You can also set custom formats on string fields:

$property->string('email')->format('email');
$property->string('website')->format('uri');
$property->string('id')->format('uuid');

Supported formats: email, uri, url, uuid, date, date-time, time, ipv4, ipv6, hostname, and more.

Nullable Fields

Mark fields as nullable to allow both the specified type and null:

$property->string('middle_name')->nullable();
// Generates: {"type": ["string", "null"]}

$property->object('address', function (Property $nested) {
    $nested->string('street')->required();
    $nested->string('city')->required();
})->nullable();
// Generates: {"type": ["object", "null"], "properties": {...}}

Unique Array Items

Ensure array items are unique:

$property->array('tags')
    ->items('string')
    ->uniqueItems();

Additional Properties Control

Control whether objects can have properties not defined in the schema:

// Allow additional properties
$property->additionalProperties(true);

// Disallow additional properties
$property->additionalProperties(false); // This is the default

// Or use the convenient strict() helper
$property->strict(); // Disallows additional properties AND marks all fields as required

Strict Mode for LLM APIs

The strict() method marks every field as required and disallows additional properties, at every level of nesting (nested objects and array item objects included). It doesn't matter where in the callback you call it.

$struct = Struct::define('User', 'A user schema', function (Property $property) {
    $property->strict();

    $property->string('fullname');
    $property->email('email');
    $property->object('address', function (Property $address) {
        $address->string('city'); // also required
    });
});

When a struct is strict, toArray() returns OpenAI's named wrapper (name, description, strict, schema) for backwards compatibility. Use toSchema() for the plain JSON schema, or one of the provider methods for a ready-to-send payload. You don't need strict() for those: each provider method applies that provider's rules itself.

Schema Metadata

Add metadata to your schemas:

$property->title('User Schema');
$property->description('Schema for user data validation');
$property->schemaVersion('https://json-schema.org/draft/2020-12/schema');
$property->id('https://example.com/schemas/user.json');

Schema Identifier ($id)

The id() method sets a unique identifier (URI) for your schema, following the JSON Schema specification. This is useful for referencing schemas and establishing canonical identifiers:

$schema = Struct::define('User', 'A user schema', function (Property $property) {
    $property->id('https://example.com/schemas/user.json');
    $property->string('name')->required();
    $property->int('age');
})->toArray();

This generates:

{
    "$id": "https://example.com/schemas/user.json",
    "type": "object",
    "properties": {
        "name": { "type": "string" },
        "age": { "type": "integer" }
    },
    "required": ["name"],
    "additionalProperties": false
}

The $id is optional and only included in the output when explicitly set.

You can also add titles to individual fields:

$property->string('email')
    ->title('Email Address')
    ->description('User\'s primary email address')
    ->format('email')
    ->required();

Complete Advanced Example

use Blaspsoft\Forerunner\Schema\Struct;
use Blaspsoft\Forerunner\Schema\Property;

$schema = Struct::define('AdvancedUser', 'Comprehensive user data structure', function (Property $property) {
    // Schema metadata
    $property->schemaVersion();
    $property->id('https://example.com/schemas/advanced-user.json');
    $property->title('Advanced User Schema');

    // Helper methods
    $property->uuid('id')->required();
    $property->email('email')->required();
    $property->url('website')->nullable();
    $property->datetime('created_at')->required();

    // Nullable nested object
    $property->object('profile', function (Property $profile) {
        $profile->string('bio')->maxLength(500);
        $profile->string('avatar_url')->format('uri');
    })->nullable();

    // Array with unique items
    $property->array('tags')
        ->items('string')
        ->uniqueItems()
        ->minItems(1)
        ->maxItems(10);

    // Advanced field configuration
    $property->string('username')
        ->title('Username')
        ->description('Unique username for the account')
        ->minLength(3)
        ->maxLength(30)
        ->pattern('^[a-zA-Z0-9_]+$')
        ->required();

    // Call strict() after all fields to mark them all as required
    $property->strict(); // Disallow extras + mark all defined fields as required
})->toArray();

This generates:

{
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://example.com/schemas/advanced-user.json",
    "type": "object",
    "title": "Advanced User Schema",
    "description": "Comprehensive user data structure",
    "properties": {
        "id": {
            "type": "string",
            "format": "uuid"
        },
        "email": {
            "type": "string",
            "format": "email"
        },
        "website": {
            "type": ["string", "null"],
            "format": "uri"
        },
        "created_at": {
            "type": "string",
            "format": "date-time"
        },
        "profile": {
            "type": ["object", "null"],
            "properties": {
                "bio": {
                    "type": "string",
                    "maxLength": 500
                },
                "avatar_url": {
                    "type": "string",
                    "format": "uri"
                }
            }
        },
        "tags": {
            "type": "array",
            "items": {
                "type": "string"
            },
            "uniqueItems": true,
            "minItems": 1,
            "maxItems": 10
        },
        "username": {
            "type": "string",
            "title": "Username",
            "description": "Unique username for the account",
            "minLength": 3,
            "maxLength": 30,
            "pattern": "^[a-zA-Z0-9_]+$"
        }
    },
    "required": ["id", "email", "created_at", "username"],
    "additionalProperties": false
}

Complex Examples

User Profile with Nested Objects

use Blaspsoft\Forerunner\Schema\Struct;
use Blaspsoft\Forerunner\Schema\Property;

$schema = Struct::define('UserProfile', 'A complete user profile schema', function (Property $property) {
    $property->string('name', 'The user\'s full name')
        ->minLength(1)
        ->maxLength(100)
        ->required();

    $property->string('email', 'The user\'s email')
        ->pattern('^[^\s@]+@[^\s@]+\.[^\s@]+$')
        ->required();

    $property->int('age', 'The user\'s age')
        ->min(0)
        ->max(150);

    $property->boolean('is_active', 'Is the account active?')
        ->default(true);

    $property->array('tags', 'User tags')
        ->items('string')
        ->minItems(0)
        ->maxItems(10);

    $property->object('address', function (Property $address) {
        $address->string('street', 'Street name')->required();
        $address->string('city', 'City name')->required();
        $address->string('state', 'State/Province')->required();
        $address->string('zip', 'ZIP/Postal code')->required();
        $address->string('country', 'Country code')->required();
    }, 'User\'s address');

    $property->enum('role', ['admin', 'moderator', 'user'], 'User role')
        ->default('user');
})->toArray();

Blog Post with Comments

$schema = Struct::define('BlogPost', 'A blog post with author and comments', function (Property $property) {
    $property->string('title')->required();
    $property->string('content')->required();
    $property->string('slug')->pattern('^[a-z0-9-]+$')->required();

    $property->object('author', function (Property $author) {
        $author->string('name')->required();
        $author->string('email')->required();
        $author->string('bio');
    })->required();

    $property->array('comments')->items('object', function (Property $comment) {
        $comment->string('text')->required();
        $comment->string('author_name')->required();
        $comment->string('author_email')->required();
        $comment->int('timestamp')->required();
    });

    $property->array('tags')->items('string')->minItems(1);

    $property->enum('status', ['draft', 'published', 'archived'])
        ->default('draft');

    $property->int('views')->min(0)->default(0);
})->toArray();

LLM Providers

Each provider has its own structured output format and supports a different subset of JSON Schema. Forerunner builds the payload for you and adjusts the schema to fit.

$struct = Struct::define('Invoice', 'An invoice', function (Property $property) {
    $property->string('number')->required();
    $property->float('total')->min(0)->required();
    $property->string('notes')->maxLength(500);
});
Method Use it as What it changes
toOpenAI() Chat Completions response_format Every field becomes required; optional fields become nullable; all objects closed
toOpenAIResponses() Responses API text.format Same as above
toAnthropic() Claude Messages API output_config.format Unsupported constraints (minimum, maxLength, minItems, ...) are moved into the field description; all objects closed
toAnthropicTool() A strict Claude tool definition (tools[]) Same as above
toGemini() Gemini generationConfig Unenforced constraints (minLength, maxLength, uniqueItems) are moved into the field description
toSchema() Anything else Nothing: the plain JSON schema

$schema and $id are left out of provider payloads. Names are made safe for APIs that only accept letters, numbers, _ and -.

OpenAI

$response = OpenAI::chat()->create([
    'model' => 'gpt-5',
    'messages' => [['role' => 'user', 'content' => 'Extract the invoice...']],
    'response_format' => $struct->toOpenAI(),
]);

Anthropic

$message = $client->messages->create(
    model: 'claude-opus-5-5',
    maxTokens: 16000,
    messages: [['role' => 'user', 'content' => 'Extract the invoice...']],
    outputConfig: ['format' => $struct->toAnthropic()],
);

Gemini

Http::withHeaders(['x-goog-api-key' => $key])->post($url, [
    'contents' => [['parts' => [['text' => 'Extract the invoice...']]]],
    'generationConfig' => $struct->toGemini(),
]);

Choosing a provider at runtime

use Blaspsoft\Forerunner\Providers\Provider;

$struct->for(Provider::Anthropic);
$struct->for(config('ai.provider')); // 'openai', 'anthropic' or 'gemini'

Working with Generated Schemas

The Struct::define() method returns a Struct object that can be converted to an array or JSON.

Convert to Array

$struct = Struct::define('User', 'A user schema', function (Property $property) {
    $property->string('name')->required();
    $property->string('email')->required();
});

// Convert to array
$array = $struct->toArray();

JSON Serialization

The Struct object implements JsonSerializable, so you can use it directly with json_encode():

$struct = Struct::define('User', 'A user schema', function (Property $property) {
    $property->string('name')->required();
});

// Automatic JSON serialization
$json = json_encode($struct, JSON_PRETTY_PRINT);

Using Structure Classes

When using the make:struct command, you can create reusable schema classes:

// In your structure class (generated by make:struct)
class UserProfile
{
    public static function schema(): Struct
    {
        return Struct::define('user_profile', 'A user profile schema', function (Property $property) {
            $property->string('name')->required();
            $property->string('email')->required();
        });
    }
}

// Using the structure
UserProfile::schema()->toSchema();     // Plain JSON schema array
UserProfile::schema()->toAnthropic();  // Or any provider payload

// For JSON, use json_encode
$json = json_encode(UserProfile::schema(), JSON_PRETTY_PRINT);

Testing

Run the test suite:

composer test

Run tests with coverage:

composer test-coverage

Code Quality

Run PHPStan analysis:

composer analyse

Format code with Laravel Pint:

composer format

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.