Many Laravel developer jobs require not only creating APIs but also interacting with other 3rd-party APIs. In this lengthy tutorial, we will show four examples of such APIs:
Let's go, one by one.
For the first project in this course, we will use the public API for Stackoverflow to get the latest ten questions and save them in the DB. We will create an Artisan command and output how many new questions were created for this.
Note that public APIs usually have very low rate limits.
Before creating Migrations with Models, we must know what we will save. First, we must check what result the response will give. We will use the advanced search API for which docs are found here.
For this example, we will keep it simple, and for questions will save title
, link
, creation_data
, and is_answered
. Also, we will save the name
of the tags for questions.
php artisan make:model StackOverflowQuestion -mphp artisan make:model StackOverflowTag -mphp artisan make:migration "create stack overflow question tags table"
database/migrations/xxx_create_stack_overflow_questions_table.php:
public function up(): void{ Schema::create('stack_overflow_questions', function (Blueprint $table) { $table->id(); $table->string('title'); $table->string('link'); $table->dateTime('creation_date'); $table->boolean('is_answered')->default(false); $table->timestamps(); });}
app/Models/StackOverflowQuestion:
use Illuminate\Database\Eloquent\Relations\BelongsToMany; class StackOverflowQuestion extends Model{ protected $fillable = [ 'title', 'link', 'creation_date', 'is_answered', ]; public function tags(): BelongsToMany { return $this->belongsToMany(StackOverflowTag::class, 'stack_overflow_question_tags'); }}
database/migrations/xxx_create_stack_overflow_questions_table.php:
public function up(): void{ Schema::create('stack_overflow_questions', function (Blueprint $table) { $table->id(); $table->string('name'); $table->timestamps(); });}
app/Models/StackOverflowTag:
class StackOverflowTag extends Model{ protected $fillable = [ 'name', ];}
database/migrations/xxx_create_stack_overflow_question_tags_table.php:
use App\Models\StackOverflowTag;use App\Models\StackOverflowQuestion; return new class extends Migration { public function up(): void { Schema::create('stack_overflow_question_tags', function (Blueprint $table) { $table->id(); $table->foreignIdFor(StackOverflowQuestion::class)->constrained(); $table->foreignIdFor(StackOverflowTag::class)->constrained(); $table->timestamps(); }); }}
Now let's create and artisan command.
php artisan make:command StackoverflowSyncPostsCommand
app/Console/Commands/StackoverflowSyncPostsCommand.php:
class StackoverflowSyncPostsCommand extends Command{ protected $signature = 'stackoverflow:sync-posts'; protected $description = 'Command description'; public function handle(): void { }}
For making an HTTP request, we will use Laravel's wrapper around Guzzle. And to the request based on the API docs we must pass GET Request query parameters.
app/Console/Commands/StackoverflowSyncPostsCommand.php:
use Illuminate\Support\Facades\Http; class StackoverflowSyncPostsCommand extends Command{ protected $signature = 'stackoverflow:sync-posts'; protected $description = 'Command description'; public function handle(): void { $response = Http::get('https://api.stackexchange.com/2.3/search/advanced', [ 'order' => 'desc', 'sort' => 'creation', 'tagged' => 'laravel', 'site' => 'stackoverflow', 'pagesize' => '10', ]); }}
Next, we will create a new private method where the whole logic for creating questions and tags will be.
app/Console/Commands/StackoverflowSyncPostsCommand.php:
use Illuminate\Support\Carbon;use App\Models\StackOverflowTag;use Illuminate\Http\Client\Response;use App\Models\StackOverflowQuestion;use GuzzleHttp\Promise\PromiseInterface; class StackoverflowSyncPostsCommand extends Command{ protected $signature = 'stackoverflow:sync-posts'; protected $description = 'Command description'; public function handle(): void { $response = Http::get('https://api.stackexchange.com/2.3/search/advanced', [ 'order' => 'desc', 'sort' => 'creation', 'tagged' => 'laravel', 'site' => 'stackoverflow', 'pagesize' => '10', ]); $this->saveQuestions($response); } private function saveQuestions(PromiseInterface|Response $response): int { $questions = json_decode($response->body())->items; $newQuestions = 0; foreach ($questions as $question) { $questionTags = []; foreach ($question->tags as $tag) { $questionTags[] = StackOverflowTag::firstOrCreate(['name' => $tag])->id; } $savedQuestion = StackOverflowQuestion::updateOrCreate( [ 'link' => $question->link, ], [ 'title' => $question->title, 'link' => $question->link, 'creation_date' => Carbon::createFromTimestamp($question->creation_date), 'is_answered' => $question->is_answered, ] ); if ($savedQuestion->wasRecentlyCreated) { $newQuestions++; } if ($savedQuestion->tags()->count() === 0) { $savedQuestion->tags()->attach($questionTags); } } return $newQuestions; } }
First, because the response from this API is always a JSON, we need to decode it grab the items
key.
Then, we do the usual Laravel logic in the foreach loop. We get or create the tag based on name using the firstOrCreate
method.
And then, using the updateOrCreate
method, we create a new question based on the link.
Lastly, if the question is created, we increase the $newQuestions
counter, attach the tags to questions, and return how many new questions were created.
Now, where we call the saveQuestions
private method in the handle
method, we can assign it to a variable and use this variable to show the output of the command.
app/Console/Commands/StackoverflowSyncPostsCommand.php:
class StackoverflowSyncPostsCommand extends Command{ protected $signature = 'stackoverflow:sync-posts'; protected $description = 'Command description'; public function handle(): void { $response = Http::get('https://api.stackexchange.com/2.3/search/advanced', [ 'order' => 'desc', 'sort' => 'creation', 'tagged' => 'laravel', 'site' => 'stackoverflow', 'pagesize' => '10', ]); $this->saveQuestions($response); $newQuestions = $this->saveQuestions($response); $this->components->info('Added new questions: ' . $newQuestions); } // ...}
For the second example of using public API, we will create a simple application showing the USD currency rate.
For the API, we will use fawazahmed0/currency-api.
First, we need a list of currencies, their name, and ISO code. We can get the list from the same API and use the artisan command to them in the DB.
database/migrations/xxx_create_currencies_table.php:
Schema::create('currencies', function (Blueprint $table) { $table->id(); $table->string('iso'); $table->string('name'); $table->timestamps();});
php artisan make:command GetCurrenciesCommand
app/Console/Commands/GetCurrenciesCommand.php:
use App\Models\Currency;use Illuminate\Console\Command;use Illuminate\Support\Facades\Http; class GetCurrenciesCommand extends Command{ protected $signature = 'get:currencies'; protected $description = 'Command description'; public function handle(): void { $currencies = Http::get('https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies.json') ->collect() ->filter(); foreach ($currencies as $iso => $name) { Currency::create([ 'iso' => $iso, 'name' => $name, ]); } $this->components->info('Done.'); }}
Some currencies in the API have no name, so we filter them out.
For this, we will need a controller.
php artisan make:controller HomeController --invokable
routes/web.php:
use App\Http\Controllers\HomeController; Route::get('/', HomeController::class)->name('home');
app/Http/Controllers/HomeController.php:
use App\Models\Currency;use Illuminate\Http\Request;use Illuminate\Support\Facades\Http; class HomeController extends Controller{ public function __invoke(Request $request) { $rate = null; $currencies = Currency::pluck('name', 'iso'); if ($request->has('currency')) { $response = Http::get("https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies/usd/{$request->input('currency')}.json") ->collect() ->toArray(); $rate = $response[$request->input('currency')]; } return view('home', compact('currencies', 'rate')); }}
If we get currency from the request, then the API request will be made, and the rate value will be assigned to the $rate
variable.
Now, let's add a select input and show the rate result.
resources/views/home.blade.php:
<form action="{{ route('home') }}" method="get"> <div> <label for="currency" class="block">Select currency:</label> <select name="currency" id="currency" class="mt-1"> <option value="">Select currency</option> @foreach($currencies as $iso => $name) <option value="{{ $iso }}" @selected(old('currency', request()->input('currency')) == $iso)>{{ $name }}</option> @endforeach </select> <x-input-error :messages="$errors->get('currency')" class="mt-2" /> </div> <x-primary-button class="mt-4"> Submit </x-primary-button></form> @if(! is_null($rate)) <div class="mt-6"> 1 USD = {{ $rate }} {{ strtoupper(request()->input('currency')) }} </div>@endif
And that's it for this example.
In this example, we will check a public API with limited daily requests for a free plan and how to deal with it.
And later, we will discuss how to do a case when one API depends on another.
We will use the Open-Meteo Weather Forecast API for the API.
First, we will make a selection of cities, for example. For simplicity, the cities list will be added to a config.
config/app.php:
return [ // ... 'cities' => [ 'london' => ['lng' => '-0.1262', 'lat' => '51.5002'], 'new york' => ['lng' => '-74.01', 'lat' => '40.71'], 'tokyo' => ['lng' => '139.6823', 'lat' => '35.6785'] ], ];
So first, the select input.
resources/views/home.blade.php:
<div class="p-6 bg-white border-b border-gray-200"> <div class="flex items-center"> <select class="flex-1 ml-4 rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"> <option value="">-- Select city --</option> @foreach(config('app.cities') as $key => $name) <option value="{{ $key }}">{{ Str::title($key) }}</option> @endforeach </select> </div></div>
After selecting the input, we will request an internal API using Alpine.js. First, we need a Route and a Controller.
php artisan make:controller Api/WeatherController
routes/api.php:
Route::get('weather/{city}', \App\Http\Controllers\Api\WeatherController::class);
We will be sending the city name through API.
app/Http/Controllers/Api/WeatherController.php:
use Illuminate\Support\Facades\Http;use Illuminate\Support\Facades\Cache;use Symfony\Component\HttpFoundation\JsonResponse; class WeatherController extends Controller{ public function __invoke(string $city): mixed { $coordinates = config('app.cities.'.$city); return Cache::remember('city' . $city, 60 * 5, function() use ($coordinates) { $response = Http::get('https://api.open-meteo.com/v1/forecast?latitude='.$coordinates['lat'].'&longitude='.$coordinates['lng'].'&daily=temperature_2m_max,temperature_2m_min&timezone=UTC'); if ($response->successful()) { return $response->json('daily'); } return response()->json([]); }); }}
In the Controller, we get city coordinates from the Config. Then, we save into Cache for five minutes the response from the API. The API call won't be made if the Cache hasn't expired yet.
The link for the API is taken from the documentation in the API Response section.
Now, let's add the JS logic. Alpine.js comes with the Laravel Breeze starter package which we are using here.
resources/views/home.blade.php:
<x-app-layout> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> {{ __('Upcoming weather') }} </h2> </x-slot> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg"> <div <div x-data="weather()" class="p-6 bg-white border-b border-gray-200"> <div class="flex items-center"> <select <select x-model="city" @change="getWeather()" class="flex-1 ml-4 rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"> <option value="">-- Select city --</option> @foreach(config('app.cities') as $key => $name) <option value="{{ $key }}">{{ Str::title($key) }}</option> @endforeach </select> </div> <template x-if="loading"> <div class="loader bg-white p-5 my-4 rounded-full flex space-x-3 justify-center"> <div class="w-5 h-5 bg-red-800 rounded-full animate-bounce"></div> <div class="w-5 h-5 bg-green-800 rounded-full animate-bounce"></div> <div class="w-5 h-5 bg-blue-800 rounded-full animate-bounce"></div> </div> </template> <template x-if="error != ''"> <div x-text="error" class="mt-4 text-red-600"></div> </template> <template x-if="!loading"> <div class="overflow-hidden overflow-x-auto mt-6 min-w-full align-middle sm:rounded-md"> <table class="min-w-full border divide-y divide-gray-200"> <thead> <tr> <template x-for="day in weather.time"> <th class="px-6 py-3 bg-gray-50"> <span class="text-xs font-medium tracking-wider leading-4 text-left text-gray-500 uppercase" x-text="day"></span> </th> </template> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200 divide-solid"> <tr class="bg-white"> <template x-for="max in weather.temperature_2m_max"> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> Max. temp. <span x-text="max"></span> </td> </template> </tr> <tr class="bg-white"> <template x-for="min in weather.temperature_2m_min"> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> Min. temp. <span x-text="min"></span> </td> </template> </tr> </tbody> </table> </div> </template> </div> </div> </div> </div> @section('scripts') <script> document.addEventListener('alpine:init', () => { Alpine.data('weather', () => ({ city: '', weather: {}, loading: false, error: '', getWeather() { this.error = '' this.weather = {} if (this.city === '') { return; } this.loading = true fetch('/api/weather/' + this.city) .then((res) => res.json()) .then((res) => { if (!res.temperature_2m_max) { this.error = 'Error happened when fetching the API' } else { this.weather = res } this.loading = false }) } })) }) </script> @endsection </x-app-layout>
The GitHub repository can be found here.
If you want to watch a video version of this part, there is a YouTube video Laravel HTTP Client + Alpine.js: External Weather API Demo.
For this part, the Alpine.js code will be the same. We will only change the select input to a text in the frontend.
resources/views/home.blade.php:
// ...<div x-data="weather()" class="p-6 bg-white border-b border-gray-200"> <div class="flex flex-col space-y-1"> <x-input x-model="city" class="flex-1" placeholder="Enter city name" /> <div> <x-button @click="getWeather()"> Submit </x-button> </div> </div> <div class="flex items-center"> <select x-model="city" @change="getWeather()" class="flex-1 ml-4 rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"> <option value="">-- Select city --</option> @foreach(config('app.cities') as $key => $name) <option value="{{ $key }}">{{ Str::title($key) }}</option> @endforeach </select> </div> // ...</x-app-layout>
We will use the API Ninjas service to get the coordinates from the city name. This service has a limit of ten thousand API calls per month for a free plan.
In the examples page, the API key needs to be sent with the headers.
Of course, this can be using the Laravel's HTTP wrapper using the withHeaders
method. We must send the X-Api-Key
header. For the API URL it is provided in the City API page.
First, must add a config value.
config/services.php:
return [ // ... 'api_ninjas' => [ 'key' => env('API_NINJAS_KEY'), ], ];
Don't forget to add env value.
Now, let's change the Controller.
app/Http/Controllers/Api/WeatherController.php:
<?php namespace App\Http\Controllers\Api; use Illuminate\Support\Facades\Http;use App\Http\Controllers\Controller;use Illuminate\Support\Facades\Cache;use Symfony\Component\HttpFoundation\JsonResponse; class WeatherController extends Controller{ public function __invoke(string $city): mixed { $coordinates = config('app.cities.'.$city); return Cache::remember('city' . $city, 60 * 5, function() use ($coordinates) { $response = Http::get('https://api.open-meteo.com/v1/forecast?latitude='.$coordinates['lat'].'&longitude='.$coordinates['lng'].'&daily=temperature_2m_max,temperature_2m_min&timezone=UTC'); if ($response->successful()) { return $response->json('daily'); } return response()->json([]); }); return Cache::remember('city' . $city, 60 * 5, function () use ($city) { $response = Http::withHeaders([ 'X-Api-Key' => config('services.api_ninjas.key'), ]) ->get('https://api.api-ninjas.com/v1/city?name=' . $city); if ($response->successful() && ! empty($response->json())) { $city = $response->json(0); $weather = Http::get('https://api.open-meteo.com/v1/forecast?latitude=' . $city['latitude'] . '&longitude=' . $city['longitude'] . '&daily=temperature_2m_max,temperature_2m_min&timezone=UTC'); if ($weather->successful()) { return $weather->json('daily'); } return response()->json([]); } return response()->json([]); }); }}
As you can see, there's not much difference. We make the first request, and if we have results, only then we make the second API request.
The GitHub repository can be found here.
We saved the best for last, so to speak. Let's build something practical using the OpenAI API.
Notice: this API has limited free tier, so be careful with the cost of API requests.
We will make a simple form where, from the entered title using OpenAI API, we will receive suggestions for better titles to improve SEO. After clicking on the suggested title, it will be set.
For making API, we will use the official openai-php/laravel
package for Laravel inside a Livewire component. So, first, let's install that package and Livewire.
For the template, I will be using Laravel Breeze.
composer require livewire/livewirecomposer require openai-php/laravel
Set the required keys in the .env
.
OPENAI_API_KEY=OPENAI_ORGANIZATION=
Let's create a Livewire component. For this example, I will be using a full-page Livewire Component.
php artisan make:livewire CreatePost
routes/web.php:
Route::get('create-post', \App\Livewire\CreatePost::class)->name('create.post');
Let's add a simple form.
resources/views/livewire/create-post.blade.php:
<div> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> {{ __('Create Post') }} </h2> </x-slot> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm dark:bg-gray-800 sm:rounded-lg"> <div class="p-6 text-gray-900 dark:text-gray-100"> <form wire:submit="save"> <div> <x-input-label for="title" :value="__('Title')" /> <x-text-input wire:model.live.debounce="title" id="title" class="mt-1 block w-full" type="text" required /> <x-input-error :messages="$errors->get('title')" class="mt-2" /> </div> <div wire:loading wire:target="suggestTitles"> Loading </div> <div class="mt-4"> <x-input-label for="content" :value="__('Content')" /> <textarea id="content" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm" required></textarea> <x-input-error :messages="$errors->get('email')" class="mt-2" /> </div> <div class="mt-4"> <x-primary-button> {{ __('Save') }} </x-primary-button> </div> </form> </div> </div> </div> </div></div>
app/Livewire/CreatePost.php:
use Livewire\Attributes\Rule;use Livewire\Attributes\Layout;use Illuminate\Contracts\View\View; #[Layout('layouts.app')]class CreatePost extends Component{ #[Rule('required')] public string $title = ''; public function render(): View { return view('livewire.create-post'); }}
The layout attribute is used because Breeze adds layout in a different directory than the default Livewire is.
Now, let's make the API call. In the API, we will send the message with the current blog post title and ask for suggestions on five titles for better SEO.
We will use the basic chat function for this example. First, let's add a button.
resources/views/livewire/create-post.blade.php:
// ...<x-input-label for="title" :value="__('Title')" /><x-text-input wire:model.live.debounce="title" id="title" class="mt-1 block w-full" type="text" required /><x-input-error :messages="$errors->get('title')" class="mt-2" /> <x-secondary-button wire:click="suggestTitles" class="mt-1"> Alternative title suggestions</x-secondary-button> // ...
After clicking this button, the suggestTitles
method in the Component will be called. First, we must validate only the title field. Then we must make the API call in the try catch
block. Using API, there could be some unexpected problems. This way, we can show a message to a user.
app/Livewire/CreatePost.php:
use Exception;use OpenAI\Laravel\Facades\OpenAI;use Illuminate\Validation\ValidationException; #[Layout('layouts.app')]class CreatePost extends Component{ #[Rule('required')] public string $title = ''; public function suggestTitles(): void { $this->validateOnly('title'); try { $result = OpenAI::chat()->create([ 'model' => 'gpt-3.5-turbo', 'messages' => [ ['role' => 'user', 'content' => 'This is a blog post title:'.PHP_EOL . $this->title . PHP_EOL . 'Improve it for SEO and provide 5 alternative titles'], ], ]); } catch (Exception $e) { throw ValidationException::withMessages(['title' => 'Something went wrong. Try again.']); } } // ...}
The response we get from this call:
We want the choices > 0 > message > content
value. But now, when the button is clicked user doesn't get any input, and the API call takes some time.
For such cases, there is a wire:loading
. And with the wire:target
, we can target the suggestTitles
method.
resources/views/livewire/create-post.blade.php:
// ... <form wire:submit="save"> <div> <x-input-label for="title" :value="__('Title')" /> <x-text-input wire:model.live.debounce="title" id="title" class="mt-1 block w-full" type="text" required /> <x-input-error :messages="$errors->get('title')" class="mt-2" /> <x-secondary-button wire:click="suggestTitles" class="mt-1"> Alternative title suggestions </x-secondary-button> </div> <div wire:loading wire:target="suggestTitles"> Loading </div> // ...
The text Loading
will be shown while the API call is being made.
And lastly, we must show the suggested titles. From the result, we can use the PHP function preg_split
to make an array element from every new line. Then, from that array, get the five latest elements, which will be the suggested titles. Of course, we must assign it to a public property.
app/Livewire/CreatePost.php:
#[Layout('layouts.app')]class CreatePost extends Component{ #[Rule('required')] public string $title = ''; public array $suggestedTitles = []; public function suggestTitles(): void { $this->validateOnly('title'); try { $result = OpenAI::chat()->create([ 'model' => 'gpt-3.5-turbo', 'messages' => [ ['role' => 'user', 'content' => 'This is a blog post title:'.PHP_EOL . $this->title . PHP_EOL . 'Improve it for SEO and provide 5 alternative titles'], ], ]); $this->suggestedTitles = array_slice(preg_split('/\r\n|\r|\n/', $result->choices[0]->message->content), -5, 5); } catch (Exception $e) { info($e->getMessage()); throw ValidationException::withMessages(['title' => 'Something went wrong. Try again.']); } } // ...}
Now, we can show them and make them clickable.
resources/views/livewire/create-post.blade.php:
// ... <form wire:submit="save"> <div> <x-input-label for="title" :value="__('Title')" /> <x-text-input wire:model.live.debounce="title" id="title" class="mt-1 block w-full" type="text" required /> <x-input-error :messages="$errors->get('title')" class="mt-2" /> <x-secondary-button wire:click="suggestTitles" class="mt-1"> Alternative title suggestions </x-secondary-button> @if($suggestedTitles) <div class="mt-2"> @foreach($suggestedTitles as $key => $suggestedTitle) <div class="hover:underline hover:cursor-pointer" wire:click="useTitle({{ $key }})">{{ $suggestedTitle }}</div> @endforeach </div> @endif </div> // ...
When clicked, we call the useTitle
method and pass the array's key.
app/Livewire/CreatePost.php:
use Illuminate\Support\Str; #[Layout('layouts.app')]class CreatePost extends Component{ // ... public function useTitle(int $key): void { $this->title = Str::of($this->suggestedTitles[$key]) ->after('. ') ->remove('"'); } public function render(): View { return view('livewire.create-post'); }}
Using the Laravel strings methods from the selected suggestions, we get the sentence after the number and remove the quotes.
So, for example, from 1. "Avoid These Developer Mistakes When Designing a Database Schema"
we get Avoid These Developer Mistakes When Designing a Database Schema
.
Also, some suggestions didn't have quotes while testing, but this method still works.
All the APIs in this tutorial are simple in their authentication mechanism: either public or just require an API key that is easy to create.
There are much more complex APIs that use, for example, OAuth for authentication, have more complex GET/POST endpoints, etc.
So, we decided to leave those for separate tutorials in the future. But in most cases, such complex APIs have extensive documentation, so that should be your primary source of knowledge on how to work with those APIs. Quite often, the Laravel part is pretty small, especially if there's a Laravel/PHP package wrapper, like in the case of OpenAI.