Simple LLM Tool Calling in Laravel using Prism

Overview

This article demonstrates implementing LLM tool calling in Laravel using the Prism package. If you have worked with Laravel but have not yet integrated an LLM (Large Language Model) into your application, this guide will walk you through the key concepts step by step.

Tool calling enables an LLM to invoke external functions, query databases, or call APIs to retrieve real-time information and perform actions beyond its training data. Think of it like giving an assistant access to a phone book and a calculator: the assistant decides when to look something up or perform a calculation, then uses the result to answer your question. This is a foundational pattern for building AI applications that can take actions in the real world.

End goal

We will build an artisan command that responds to natural language queries about Chicago bus stops and arrivals. When you ask a question like "When is the next bus at Madison and State?", the LLM will:

  1. Decide which tool to call based on your question
  2. Search the database for matching bus stops
  3. Query an external API for real-time arrival times
  4. Return a natural language response with the answer

LLM Bus Finder Demo

Repository

A repository for this article can be found on here on GitHub.

Key technologies

Services

  • Claude API provides the LLM that powers our application. The LLM receives your question, decides which tools to call, and generates the final response.
  • OpenCage Geocoding API converts street addresses into latitude and longitude coordinates. We need this to answer questions like "What's the nearest bus stop to 123 Main St?"
  • Chicago Transit Authority Bus Tracker API provides real-time bus arrival predictions. This is how we answer "When is the next bus?" questions.

Packages

  • Prism provides a unified API for working with different LLM providers (Claude, OpenAI, etc.). You write your code once, and Prism handles the differences between providers. It also provides built-in support for tool calling and structured output.
  • Laravel Scout (with Meilisearch) enables fuzzy search, which finds results even when the query does not exactly match. For example, searching for "Madison and State" will find "Madison & State St" in the database.
  • Laravel Eloquent Spatial adds geospatial query support, letting us find bus stops by distance from a location.
  • Laravel KML Parser parses CTA's geographic data export into a format we can work with.

Prerequisites

Getting started

Generating a new project

1laravel new laravel-llm-cta-bus-tracker
2cd laravel-llm-cta-bus-tracker

Enums, migrations, and models

We'll start by creating the data layer: string-backed enums (whose values match the CTA API responses), a migration, and a model for bus stops.

Enums

Create the BusDirection enum:

📁
app/Enums/BusDirection.php
1<?php
2 
3namespace App\Enums;
4 
5enum BusDirection: string
6{
7 case NORTH_BOUND = 'NB';
8 case SOUTH_BOUND = 'SB';
9 case EAST_BOUND = 'EB';
10 case WEST_BOUND = 'WB';
11 case NORTHEAST_BOUND = 'NEB';
12 case NORTHWEST_BOUND = 'NWB';
13 case SOUTHEAST_BOUND = 'SEB';
14 case SOUTHWEST_BOUND = 'SWB';
15}

And the BusStopPosition enum:

📁
app/Enums/BusStopPosition.php
1<?php
2 
3namespace App\Enums;
4 
5enum BusStopPosition: string
6{
7 case FAR_SIDE_OF_INTERSECTION = 'FS';
8 case NEAR_SIDE_OF_INTERSECTION = 'NS';
9 case NEAR_SIDE_OF_T_INTERSECTION = 'NT';
10 case MIDDLE_OF_BLOCK = 'MB';
11 case MIDDLE_OF_T_INTERSECTION = 'MT';
12 case FAR_SIDE_OF_T_INTERSECTION = 'FT';
13 case TERMINAL = 'TERM';
14}

Migration

Create the migration for the bus_stops table:

1php artisan make:migration CreateBusStopsTable
📁
database/migrations/YYYY_MM_DD_HHIISS_create_bus_stops_table.php
1<?php
2 
3use Illuminate\Database\Migrations\Migration;
4use Illuminate\Database\Schema\Blueprint;
5use Illuminate\Support\Facades\Schema;
6 
7return new class extends Migration
8{
9 public function up(): void
10 {
11 Schema::create('bus_stops', function (Blueprint $table) {
12 $table->id();
13 $table->string('stop_identifier')->unique();
14 $table->string('name');
15 $table->geometry('location', subtype: 'point');
16 $table->string('direction', 5);
17 $table->string('position', 5);
18 $table->timestamps();
19 });
20 }
21 
22 public function down(): void
23 {
24 Schema::dropIfExists('bus_stops');
25 }
26};

Model

Create the BusStop model with fillable attributes and enum casts:

1php artisan make:model BusStop
📁
app/Models/BusStop.php
1<?php
2 
3namespace App\Models;
4 
5use App\Enums\BusDirection;
6use App\Enums\BusStopPosition;
7use Illuminate\Database\Eloquent\Model;
8 
9class BusStop extends Model
10{
11 protected $fillable = [
12 'stop_identifier',
13 'name',
14 'location',
15 'direction',
16 'position',
17 ];
18 
19 protected $casts = [
20 'direction' => BusDirection::class,
21 'position' => BusStopPosition::class,
22 ];
23}

Geospatial support

To find the nearest bus stop to a given location, we need to calculate distances between coordinates. The matanyadaev/laravel-eloquent-spatial package adds this capability to Eloquent, letting us write queries like "find all bus stops within 1 mile" or "sort bus stops by distance from this point."

1composer require matanyadaev/laravel-eloquent-spatial

Configure the default SRID (Spatial Reference System Identifier) in AppServiceProvider. An SRID tells the database how to interpret coordinate values. We use WGS84, which is the same coordinate system that GPS devices and mapping applications use. This ensures our distance calculations are accurate:

📁
app/Providers/AppServiceProvider.php
1<?php
2 
3namespace App\Providers;
4 
5use Illuminate\Support\ServiceProvider;
6use MatanYadaev\EloquentSpatial\EloquentSpatial;
7use MatanYadaev\EloquentSpatial\Enums\Srid;
8 
9class AppServiceProvider extends ServiceProvider
10{
11 public function register(): void
12 {
13 }
14 
15 public function boot(): void
16 {
17 EloquentSpatial::setDefaultSrid(Srid::WGS84);
18 }
19}

Update the BusStop model to use the HasSpatial trait and cast location to Point. The trait adds spatial query methods like orderByDistance(), and the Point cast converts the database geometry into a PHP object with latitude and longitude properties:

📁
app/Models/BusStop.php
1<?php
2 
3namespace App\Models;
4 
5use App\Enums\BusDirection;
6use App\Enums\BusStopPosition;
7use Illuminate\Database\Eloquent\Model;
8use MatanYadaev\EloquentSpatial\Objects\Point;
9use MatanYadaev\EloquentSpatial\Traits\HasSpatial;
10 
11class BusStop extends Model
12{
13 use HasSpatial;
14 
15 protected $fillable = [
16 'stop_identifier',
17 'name',
18 'location',
19 'direction',
20 'position',
21 ];
22 
23 protected $casts = [
24 'location' => Point::class,
25 'direction' => BusDirection::class,
26 'position' => BusStopPosition::class,
27 ];
28}

KMZ/KML file support

The CTA publishes bus stop data in KML format, an XML-based format originally developed by Google for geographic data. KMZ is simply a compressed (zipped) KML file. Many transit agencies and mapping services use these formats to share location data. We will use the plin-code/kml-parser package to extract the bus stop information from CTA's data export.

1composer require plin-code/kml-parser

Bus stop data

We need to populate the bus_stops table with data from CTA's public geographic dataset. For this, we'll create an artisan command.

1php artisan make:command FillBusStopsTable

The command will:

  1. Download a KMZ archive from CTA's data portal
  2. Extract the KML document and parse stop data (identifier, name, coordinates, direction, position)
  3. Truncate and repopulate the bus_stops table
📁
app/Console/Commands/FillBusStopsTable.php
1<?php
2 
3namespace App\Console\Commands;
4 
5use App\Enums\BusDirection;
6use App\Enums\BusStopPosition;
7use App\Models\BusStop;
8use Illuminate\Console\Command;
9use Illuminate\Support\Facades\DB;
10use Illuminate\Support\Facades\File;
11use Illuminate\Support\Facades\Http;
12use MatanYadaev\EloquentSpatial\Objects\Point;
13use PlinCode\KmlParser\KmlParser;
14use RuntimeException;
15 
16class FillBusStopsTable extends Command
17{
18 /**
19 * The name and signature of the console command.
20 *
21 * @var string
22 */
23 protected $signature = 'app:fill-bus-stops-table';
24 
25 /**
26 * The console command description.
27 *
28 * @var string
29 */
30 protected $description = 'Fill bus stops table';
31 
32 /**
33 * Execute the console command.
34 */
35 public function handle(): int
36 {
37 $this->line('Downloading KMZ archive...');
38 $kmzPath = $this->downloadKmz();
39 
40 $this->line('Loading KML document from KMZ archive...');
41 $kml = \PlinCode\KmlParser\Facades\KmlParser::loadFromKmz($kmzPath);
42 
43 $this->line('Deleting KMZ archive...');
44 File::delete($kmzPath);
45 
46 $this->line('Extracting data from KML...');
47 $data = $this->extractData($kml);
48 
49 $this->line('Truncating table...');
50 DB::table('bus_stops')->truncate();
51 
52 $this->line('Inserting data...');
53 $this->fillTable($data);
54 
55 $this->getOutput()->newLine();
56 
57 $this->info('Filled bus_stops table with '.count($data).' records.');
58 
59 return static::SUCCESS;
60 }
61 
62 protected function downloadKmz(): string
63 {
64 // see: https://www.transitchicago.com/data/
65 $kmz = Http::get('https://data.cityofchicago.org/download/84eu-buny/application%2Fvnd.google-earth.kmz')
66 ->body();
67 
68 $path = storage_path('app/private/cta-bus-stops.kmz');
69 
70 if (! File::put($path, $kmz)) {
71 throw new RuntimeException('Error writing KMZ file');
72 }
73 
74 return $path;
75 }
76 
77 protected function extractData(KmlParser $kml): array
78 {
79 $data = [];
80 
81 foreach ($kml->getPlacemarks() as $index => $placemark) {
82 $stopId = $this->extractFromHtml($placemark['description'], 'SYSTEMSTOP');
83 
84 if (! $stopId) {
85 $this->warn("Failed to find system stop for index: $index");
86 
87 continue;
88 }
89 
90 $direction = $this->extractFromHtml($placemark['description'], 'DIR');
91 
92 if (! $direction) {
93 $this->warn("Failed to find direction for stop: $stopId");
94 
95 continue;
96 }
97 
98 $position = $this->extractFromHtml($placemark['description'], 'POS');
99 
100 if (! $position) {
101 $this->warn("Failed to find position for stop: $stopId");
102 
103 continue;
104 }
105 
106 $name = $placemark['name'];
107 $latitude = $placemark['coordinates']['latitude'] ?? false;
108 $longitude = $placemark['coordinates']['longitude'] ?? false;
109 
110 if (! $latitude || ! $longitude) {
111 $this->warn("Failed to find lat/lng for stop: $stopId");
112 
113 continue;
114 }
115 
116 $data[] = [
117 'stop_identifier' => $stopId,
118 'name' => $name,
119 'latitude' => $latitude,
120 'longitude' => $longitude,
121 'direction' => BusDirection::from($direction),
122 'position' => BusStopPosition::from($position),
123 ];
124 }
125 
126 return $data;
127 }
128 
129 protected function fillTable(array $data): void
130 {
131 $this->withProgressBar($data, fn ($datum) => BusStop::create([
132 'stop_identifier' => $datum['stop_identifier'],
133 'name' => $datum['name'],
134 'location' => new Point($datum['latitude'], $datum['longitude']),
135 'direction' => $datum['direction'],
136 'position' => $datum['position'],
137 ]));
138 }
139 
140 protected function extractFromHtml(string $html, string $identifier): ?string
141 {
142 preg_match("/<td>$identifier<\/td>\s*<td>(.+)<\/td>/", $html, $matches);
143 
144 return $matches[1] ?? null;
145 }
146}
The code above uses Regular Expressions to extract data from a predictable HTML pattern. In a production application, regex should not be considered reliable for processing HTML. For this demonstration, however, it's working just fine.

Fuzzy searching

When an LLM generates tool calls, the input may not exactly match our data. Here are a few examples of mismatches that can occur:

  • User asks about "Madison and State" but the database contains "Madison & State St"
  • User asks about "Clark Street" but the database contains "Clark St"
  • User misspells "Damen" as "Damien"

A traditional database query using WHERE name = 'Madison and State' would return zero results in all of these cases. Fuzzy search solves this problem by finding results that are close enough to the query, even if they do not match exactly.

We will use Meilisearch, a fast and typo-tolerant search engine, with Laravel Scout for approximate string matching:

1composer require laravel/scout
2php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
3composer require meilisearch/meilisearch-php http-interop/http-factory-guzzle

Add the Meilisearch configuration to .env:

📁
.env
1SCOUT_DRIVER=meilisearch
2MEILISEARCH_HOST=http://127.0.0.1:7700
3MEILISEARCH_KEY=LARAVEL-HERD

Add the Searchable trait to BusStop and define which fields to index:

📁
app/Models/BusStop.php
1<?php
2 
3namespace App\Models;
4 
5use App\Enums\BusDirection;
6use App\Enums\BusStopPosition;
7use Illuminate\Database\Eloquent\Model;
8use Laravel\Scout\Searchable;
9use MatanYadaev\EloquentSpatial\Objects\Point;
10use MatanYadaev\EloquentSpatial\Traits\HasSpatial;
11 
12class BusStop extends Model
13{
14 use HasSpatial;
15 use Searchable;
16 
17 protected $fillable = [
18 'stop_identifier',
19 'name',
20 'location',
21 'direction',
22 'position',
23 ];
24 
25 protected $casts = [
26 'location' => Point::class,
27 'direction' => BusDirection::class,
28 'position' => BusStopPosition::class,
29 ];
30 
31 public function toSearchableArray(): array
32 {
33 return [
34 'name' => $this->name,
35 'direction' => $this->direction->value,
36 ];
37 }
38}
We include direction in the searchable array to enable filtering search results by travel direction.

Configure the direction attribute as filterable in config/scout.php. Filterable attributes are fields that you can use to narrow down search results. In our case, we want to filter bus stops by travel direction (northbound, southbound, etc.):

📁
config/scout.php
1<?php
2 
3return [
4 // etc...
5 
6 'meilisearch' => [
7 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
8 'key' => env('MEILISEARCH_KEY'),
9 'index-settings' => [
10 \App\Models\BusStop::class => [
11 'filterableAttributes' => ['direction'],
12 ],
13 ],
14 ],
15]

Sync the index settings with Meilisearch (required for filterable attributes to work):

1php artisan scout:sync-index-settings

Using LLMs in Laravel with Prism

Prism provides a unified API for working with LLM providers in Laravel. Instead of writing provider-specific code for Claude, OpenAI, or other LLMs, you use Prism's API and it handles the differences behind the scenes. Prism supports three main output types: text output (plain responses), structured output (JSON matching a schema you define), and tool calling (which we will focus on here).

Installation and configuration

1composer require prism-php/prism
2php artisan vendor:publish --tag=prism-config

Add your Anthropic API key to .env:

📁
.env
1ANTHROPIC_API_KEY=your-api-key-here

Tools

Tool calling follows a request-response cycle. Here is how it works in our bus finder application:

  1. You send a prompt to the LLM (for example, "When is the next bus at Madison and State?") along with tool definitions that describe what each tool does and what parameters it accepts
  2. The LLM analyzes your question and decides which tool(s) to call. It returns a structured request like: "call search_bus_stops with name='Madison and State'"
  3. Your application executes the tool (searches the database) and sends the result back to the LLM
  4. The LLM either calls another tool (like next_bus_arrival to get arrival times) or returns a final response to the user

Prism handles this back-and-forth loop automatically. We just need to define our tools.

Search for bus stop

Our first tool searches the database using Laravel Scout. Each Prism tool is a PHP class that extends Tool and defines itself using a fluent builder pattern. Here is what each method does:

  • as() sets the tool's name. This is the identifier the LLM will use when it decides to call the tool.
  • for() provides a description that tells the LLM when this tool is useful. The LLM reads this description to decide whether to use this tool for a given question.
  • withStringParameter() and withEnumParameter() define what inputs the tool accepts. These become the parameters the LLM will provide when calling the tool.
  • using($this) tells Prism to call the __invoke() method on this class when the tool is executed.
📁
app/Tools/BusStopSearchTool.php
1<?php
2 
3namespace App\Tools;
4 
5use App\Enums\BusDirection;
6use App\Models\BusStop;
7use Prism\Prism\Tool;
8 
9class BusStopSearchTool extends Tool
10{
11 public function __construct()
12 {
13 $this
14 ->as('search_bus_stops')
15 ->for('useful when you need to search for bus stops by name')
16 ->withStringParameter(
17 name: 'name',
18 description: 'The name of the bus stop; typically cross streets.',
19 )
20 ->withEnumParameter(
21 name: 'direction',
22 description: 'The travel direction of the bus stop.',
23 options: collect(BusDirection::cases())
24 ->map(fn ($direction) => $direction->name)
25 ->all(),
26 required: false
27 )
28 ->using($this);
29 }
30 
31 public function __invoke(string $name, ?string $direction = null): string
32 {
33 $dir = $direction && defined(BusDirection::class.'::'.$direction)
34 ? constant(BusDirection::class.'::'.$direction)
35 : null;
36 
37 $search = str_replace(' and ', ' & ', $name);
38 
39 $busStops = BusStop::search($search)
40 ->when($dir, fn ($query) => $query->where('direction', $dir->value))
41 ->take($dir ? 3 : 6)
42 ->get();
43 
44 return view('prompts.bus-stop-search-results', [
45 'stops' => $busStops,
46 ])->render();
47 }
48}

The tool returns data via a Blade template. When the LLM calls this tool, we need to send back the results in a format it can understand. Using a Blade template keeps the formatting logic separate from the tool logic and makes it easy to adjust the output format later:

📁
resources/views/prompts/bus-stop-search-results.blade.php
1@foreach($stops as $stop)
2bus_stop_id: {{ $stop->stop_identifier }}, bus_stop_name: {{ $stop->name }}, bus_direction: {{ $stop->direction->name }}, bus_stop_position: {{ $stop->position->name }}
3@endforeach

Find nearest bus stop

This tool answers questions like "What's the nearest bus stop to 123 Main St?" It works in two steps: first, it converts the street address into latitude and longitude coordinates using the OpenCage geocoding API. Then, it uses our spatial queries to find the bus stop closest to those coordinates.

Add the OpenCage API key to .env:

📁
.env
1OPENCAGEDATA_API_KEY=your-api-key-here

Add the service configuration:

📁
config/services.php
1<?php
2 
3return [
4 // etc...
5 
6 'opencagedata' => [
7 'key' => env('OPENCAGEDATA_API_KEY'),
8 'url' => 'https://api.opencagedata.com/geocode/v1/json',
9 ],
10];

The tool geocodes the address and queries for the nearest stop. Notice that if the address does not include "Chicago", we append "Chicago, IL, USA" to help the geocoding API return accurate results. Without this, an address like "123 Main St" might match locations in other cities:

📁
app/Tools/BusStopNearestTool.php
1<?php
2 
3namespace App\Tools;
4 
5use App\Enums\BusDirection;
6use App\Models\BusStop;
7use Illuminate\Support\Facades\Http;
8use MatanYadaev\EloquentSpatial\Objects\Point;
9use Prism\Prism\Tool;
10use RuntimeException;
11 
12class BusStopNearestTool extends Tool
13{
14 public function __construct()
15 {
16 $this
17 ->as('nearest_bus_stop')
18 ->for('useful when you need to search for bus stops nearest to an address')
19 ->withStringParameter(
20 name: 'address',
21 description: 'The address of the location to search from',
22 )
23 ->withEnumParameter(
24 name: 'direction',
25 description: 'The travel direction of the bus stop.',
26 options: collect(BusDirection::cases())
27 ->map(fn ($direction) => $direction->name)
28 ->all(),
29 required: false
30 )
31 ->using($this);
32 }
33 
34 public function __invoke(string $address, ?string $direction = null): string
35 {
36 if (! stristr($address, 'chicago')) {
37 $address .= ', '.'Chicago, IL, USA';
38 }
39 
40 $point = $this->geocodeAddress($address);
41 
42 $dir = $direction && defined(BusDirection::class.'::'.$direction)
43 ? constant(BusDirection::class.'::'.$direction)
44 : null;
45 
46 $nearestStop = BusStop::orderByDistance('location', $point)
47 ->when($dir, fn ($query) => $query->where('direction', $dir->value))
48 ->first();
49 
50 if (! $nearestStop) {
51 throw new RuntimeException('Could not determine nearest bus stop');
52 }
53 
54 return view('prompts.bus-stop-search-results', [
55 'stops' => [$nearestStop],
56 ])->render();
57 }
58 
59 protected function geocodeAddress(string $address): Point
60 {
61 $response = Http::get(config('services.opencagedata.url'), [
62 'key' => config('services.opencagedata.key'),
63 'q' => $address,
64 'countrycode' => 'us',
65 'proximity' => '41.881832,-87.623177', // center of chicago
66 ]);
67 
68 if (! $response->successful()) {
69 throw new RuntimeException('Unable to geocode address');
70 }
71 
72 $lat = $response->json('results.0.geometry.lat');
73 $lng = $response->json('results.0.geometry.lng');
74 
75 if (! $lat || ! $lng) {
76 throw new RuntimeException('Unable to find lat/lng from results');
77 }
78 
79 return new Point($lat, $lng);
80 }
81}

Next bus estimated arrival

Our final tool queries the CTA Bus Tracker API for real-time arrival predictions. The CTA provides a public API that returns when the next bus will arrive at any stop. We pass in a bus stop ID (which we get from our other tools) and the API returns the estimated arrival time in minutes.

Add the CTA API key to .env:

📁
.env
1CTABUSTRACKER_API_KEY=your-api-key-here

Add the service configuration:

📁
config/services.php
1<?php
2 
3return [
4 // etc...
5 
6 'ctabustracker' => [
7 'key' => env('CTABUSTRACKER_API_KEY'),
8 'url' => 'https://ctabustracker.com/bustime/api/v2',
9 ],
10];

The tool calls the CTA API's /getpredictions endpoint and returns the minutes until arrival. When a bus is about to arrive, the API returns "DUE" instead of a number, so we handle that case by returning 0 minutes:

📁
app/Tools/BusStopNextArrivalTool.php
1<?php
2 
3namespace App\Tools;
4 
5use Illuminate\Support\Facades\Http;
6use Prism\Prism\Tool;
7use RuntimeException;
8 
9class BusStopNextArrivalTool extends Tool
10{
11 public function __construct()
12 {
13 $this
14 ->as('next_bus_arrival')
15 ->for('useful when you need to determine when the next bus will arrive at a specific stop')
16 ->withStringParameter(
17 name: 'bus_stop_id',
18 description: 'The ID of the bus stop',
19 )
20 ->using($this);
21 }
22 
23 public function __invoke(string $bus_stop_id): string
24 {
25 return $this->predictNextBus($bus_stop_id).' minutes';
26 }
27 
28 protected function predictNextBus(string $busStopId): int
29 {
30 $response = Http::get(config('services.ctabustracker.url') . '/getpredictions', [
31 'key' => config('services.ctabustracker.key'),
32 'stpid' => $busStopId,
33 'format' => 'json',
34 ]);
35 
36 if (! $response->successful()) {
37 throw new RuntimeException('Unable to predict next bus');
38 }
39 
40 $minutes = $response->json('bustime-response.prd.0.prdctdn');
41 
42 if ($minutes === null) {
43 throw new RuntimeException('Unable to find next bus prediction from results');
44 }
45 
46 return strtolower($minutes) === 'due' ? 0 : (int) $minutes;
47 }
48}

Putting it all together

Now we will create the artisan command that orchestrates everything.

Our approach uses two separate LLM calls, and understanding why helps clarify how tool calling and structured output work together:

  1. First call (structured output with tools): We send the user's question to the LLM along with our tool definitions. The LLM calls the appropriate tools to gather data, then returns the results in a predefined schema (an object with fields like bus_stop_name, bus_direction, and bus_arrival_minutes). Using structured output ensures we get reliable, typed data that our code can work with.

  2. Second call (text formatting): We take the structured data from the first call and ask the LLM to convert it into a natural language sentence. This separation keeps the data-gathering logic clean and gives us control over the final output format.

Create the system prompt for the structured output call:

📁
resources/views/prompts/bus-finder-structured.blade.php
1You are an assistant that determines information about the nearest bus stop and/or the next bus arrival at a stop.

And the prompt for formatting the structured data into a sentence:

📁
resources/views/prompts/bus-finder-formatted.blade.php
1Use this information to answer the subsequent question. Ignore any `null` fields. Do not add any markdown formatting. Respond with a single sentence and nothing else.
2 
3{{ collect($data)->map(fn ($value, $key) => "$key: $value")->implode(', ') }}

Make the new command.

1php artisan make:command BusFinder

Here is what each key configuration option does:

  • withMaxSteps(3) allows up to 3 rounds of tool calls before the LLM must return a final response. For our use case, the LLM might first search for a bus stop, then get arrival times, so we need at least 2 steps.
  • withSchema() defines the exact structure of data we expect back. Prism provides schema classes like StringSchema, NumberSchema, and EnumSchema to describe each field.
  • withToolChoice(ToolChoice::Any) requires the LLM to call at least one tool. Without this, the LLM might try to answer from its training data instead of using our real-time tools.
  • The PromptsForMissingInput interface tells Laravel to prompt for the query argument if the user does not provide it when running the command.
📁
app/Console/Commands/BusFinder.php
1<?php
2 
3namespace App\Console\Commands;
4 
5use App\Enums\BusDirection;
6use App\Enums\BusStopPosition;
7use App\Tools\BusStopNearestTool;
8use App\Tools\BusStopNextArrivalTool;
9use App\Tools\BusStopSearchTool;
10use Illuminate\Console\Command;
11use Illuminate\Contracts\Console\PromptsForMissingInput;
12use Prism\Prism\Enums\Provider;
13use Prism\Prism\Enums\ToolChoice;
14use Prism\Prism\Facades\Prism;
15use Prism\Prism\Facades\Tool;
16use Prism\Prism\Schema\EnumSchema;
17use Prism\Prism\Schema\NumberSchema;
18use Prism\Prism\Schema\ObjectSchema;
19use Prism\Prism\Schema\StringSchema;
20 
21use function Laravel\Prompts\spin;
22 
23class BusFinder extends Command implements PromptsForMissingInput
24{
25 protected const string MODEL = 'claude-sonnet-4-5-20250929';
26 
27 /**
28 * The name and signature of the console command.
29 *
30 * @var string
31 */
32 protected $signature = 'app:bus-finder {query}';
33 
34 /**
35 * The console command description.
36 *
37 * @var string
38 */
39 protected $description = 'Find a bus stop or next bus';
40 
41 /**
42 * Execute the console command.
43 */
44 public function handle(): int
45 {
46 spin(
47 callback: function () use (&$formatted) {
48 $response = Prism::structured()
49 ->using(Provider::Anthropic, static::MODEL)
50 ->withMaxSteps(3)
51 ->withProviderOptions(['use_tool_calling' => true])
52 ->withSchema($this->getOutputSchema())
53 ->withSystemPrompt(view('prompts.bus-finder-structured'))
54 ->withPrompt($this->argument('query'))
55 ->withTools([
56 Tool::make(BusStopSearchTool::class),
57 Tool::make(BusStopNearestTool::class),
58 Tool::make(BusStopNextArrivalTool::class),
59 ])
60 ->withToolChoice(ToolChoice::Any)
61 ->asStructured();
62 
63 $formatted = Prism::text()
64 ->using(Provider::Anthropic, static::MODEL)
65 ->withSystemPrompt(view('prompts.bus-finder-formatted', ['data' => $response->structured]))
66 ->withPrompt($this->argument('query'))
67 ->asText();
68 },
69 message: 'thinking...',
70 );
71 
72 $this->info($formatted->text);
73 
74 return static::SUCCESS;
75 }
76 
77 protected function getOutputSchema(): ObjectSchema
78 {
79 return new ObjectSchema(
80 name: 'bus_finder_results',
81 description: 'Results from searching for a next bus and/or bus stop.',
82 properties: [
83 new StringSchema(
84 name: 'bus_stop_name',
85 description: 'The name of the bus stop',
86 nullable: true,
87 ),
88 new EnumSchema(
89 name: 'bus_direction',
90 description: 'The direction of the bus',
91 options: collect(BusDirection::cases())
92 ->map(fn ($direction) => $direction->name)
93 ->all(),
94 nullable: true,
95 ),
96 new EnumSchema(
97 name: 'bus_stop_position',
98 description: 'The position of the bus stop relative to the street',
99 options: collect(BusStopPosition::cases())
100 ->map(fn ($position) => $position->name)
101 ->all(),
102 nullable: true,
103 ),
104 new NumberSchema(
105 name: 'bus_arrival_minutes',
106 description: 'When the next bus will arrive at a specific stop, in minutes',
107 nullable: true,
108 minimum: 0,
109 ),
110 ],
111 );
112 }
113}

Running the commands

Migrate the database and populate it with bus stop data:

1php artisan migrate
2php artisan app:fill-bus-stops-table

LLM Bus Finder Fill Command

Try the bus finder:

1php artisan app:bus-finder

LLM Bus Finder Demo

Conclusion

We built an LLM-powered CLI that autonomously searches databases, geocodes addresses, and queries external APIs. Claude handles the orchestration through Prism's tool calling interface, deciding which tools to call and when.

Here are the key patterns we demonstrated:

  • Tool definitions as PHP classes: Each tool encapsulates its logic and provides a clear description that tells the LLM when to use it.
  • Structured output: By defining a schema, we ensure the LLM returns reliable, typed data that our code can work with.
  • Two-step prompting: We separate data gathering (with tools) from natural language formatting, giving us more control over both.

This pattern extends naturally to more complex scenarios. You could add multi-turn conversations where the LLM remembers context, tool chains where one tool's output feeds into another, or agents that maintain state across multiple requests. The Prism package handles the underlying complexity, letting you focus on defining what your tools do and how they help answer user questions.

🤖
Did you spot a mistake in this article? Have a suggestion for how something can be improved? Even if you'd just like to comment or chat about something else, I'd love to hear from you! Contact me.

Syntax highlighting by Torchlight.dev

End of article