DEV Community

Cover image for External APIs as Eloquent models in Laravel with Larasources
Eduardo Lázaro
Eduardo Lázaro

Posted on

External APIs as Eloquent models in Laravel with Larasources

Every integration starts a client with the endpoints, a function that turns your model into their payload, a column or two to remember what the other side answered, and a job that pushes changes.

Then the second portal arrives and you write it again, because the shape of their JSON is different even though everything around it is identical.

What gets you is not the HTTP. It is the four things around it:

  1. Where the mapping lives.
  2. Where the remote state is cached.
  3. How you know whether what the screen shows is current.
  4. Whose credentials you are using.

Larasources is those four things. A Source is a model-like class that declares the remote resource with fillable fields, casts, accessors, and a mapping from your record.

An Origin is the client that knows how to fetch, save and delete. The cached state lives in one dedicated table instead of leaking into your schema, and nothing about the package assumes the other side is even an API, as one of the apps using it reads a car listing by scraping the page.

How to install

Just install it with composer as usually:

composer require edulazaro/larasources
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

There is no config file to publish and no environment variable to set.

The source: the shape retrieved

A Source looks like a model because it behaves like one, minus the table:

namespace App\Sources;

use EduLazaro\Larasources\Source;
use EduLazaro\Larasources\Attributes\UsesOrigin;
use App\Origins\WeatherOrigin;

#[UsesOrigin(WeatherOrigin::class)]
class WeatherSource extends Source
{
    protected $fillable = ['temperature', 'humidity', 'description'];

    protected $casts = [
        'temperature' => 'float',
        'humidity'    => 'integer',
    ];

    protected function arguments(): array
    {
        return [
            'city_id' => 'external_id', // the origin receives $city->external_id
        ];
    }

    public function getFeelsLikeAttribute(): float
    {
        return $this->temperature - ($this->humidity / 10);
    }
}
Enter fullscreen mode Exit fullscreen mode

The origin: integrating the remote client

The Origin is where the protocol lives, and it never sees your mapping:

namespace App\Origins;

use EduLazaro\Larasources\Origins\Origin;

class WeatherOrigin extends Origin
{
    public static function getAlias(): string
    {
        return 'weather';
    }

    public function fetch(array $arguments = []): array
    {
        return $this->http()
            ->withToken($this->config('api_key'))
            ->get('https://api.example.com/weather/' . $arguments['city_id'])
            ->json();
    }

    public function save(array $data): array
    {
        return $this->http()
            ->withToken($this->config('api_key'))
            ->post('https://api.example.com/weather', $data)
            ->json();
    }
}
Enter fullscreen mode Exit fullscreen mode

Attaching a source to a model

use Illuminate\Database\Eloquent\Model;
use EduLazaro\Larasources\Concerns\HasSources;

class City extends Model
{
    use HasSources;

    protected array $sources = [
        'weather' => WeatherSource::class,
    ];
}
Enter fullscreen mode Exit fullscreen mode

Then read it like anything else:

$weather = $city->source('weather');

$weather->temperature;   // 21.5
$weather->feels_like;    // the accessor, over cached data
Enter fullscreen mode Exit fullscreen mode

Reading serves the cache

Touching an attribute autoloads. It fills from the cached record if there is one, and goes to the origin if there is not.

Asking for live data is explicit, and it refreshes the cache on the way out:

$city->source('weather')->fetch();   // live from the origin, cache refreshed
$city->source('weather')->save();    // push your attributes, then cache
$city->source('weather')->clear();   // drop the cached record
Enter fullscreen mode Exit fullscreen mode

fetch() always writes to the record, and there is no flag to turn that off.

A cache is the local picture of something remote. A live read that leaves the old copy behind turns the next screen into a lie, and nobody gets told.

What the package will not do is expire that cache on its own. Once the record exists, reading attributes never calls the origin again, and you refresh when your app decides to: on a schedule, on a button, when a webhook says something changed.

A TTL that fires on attribute access sounds handy until a listing page paints 20 cards and makes 20 API calls.

The cached record is a plain Eloquent model, so comparing both pictures needs nothing special. What you have stored sits under its attributes key, and fetch() gives you what the service says right now:

$stored = $source->record()->toArray();   // name, variant, origin, attributes, dates
$live   = $source->fetch()->toArray();    // the fields, straight from the service
Enter fullscreen mode Exit fullscreen mode

Writing, and what the service answers

save() builds, pushes, and writes the record after the push returns. That order is the whole design: when the push fails the record is not touched, so the local copy can fall behind the service, which the next write or read fixes, but it never gets ahead of it. A row claiming something exists on the other side when it does not is the failure you never recover from, because nothing ever goes looking for it again.

Failure arrives as an exception, which is what a queued job wants: catch, back off, retry. A screen wants the opposite, so there is a twin that reports instead of throwing:

$result = $property->source('portal')->trySave();

if ($result->failed()) {
    return back()->withErrors($result->message);
}
Enter fullscreen mode Exit fullscreen mode

What counts as a failure is decided inside the origin, never by the package. Returning means the service took the data. Reading a response to guess whether it worked would be the package pretending to understand an API it has never seen.

And some services take the payload and finish with it later, so the origin can say that too:

return new OriginResult(
    status: $response['published'] ? OriginStatus::Saved : OriginStatus::Processing,
    data: $response,
    externalId: $response['id'] ?? null,
);
Enter fullscreen mode Exit fullscreen mode

A processing row is not a lie and not the truth yet. What is pending is a query, and resolving it happens where the latency is yours to spend:

foreach (SourceRecord::processing()->where('updated_at', '<', now()->subHour())->get() as $record) {
    $record->toSource(ListingSource::class)->reconcile();
}
Enter fullscreen mode Exit fullscreen mode

Not on attribute access. A listing page is not the place to discover that forty rows each need an API call.

What the service calls it

Their id for the thing you just created is the one part of a write response worth keeping, and every integration ends up with a column for it: provider_listing_id, beat_id, whatever you named yours. It lives on the record now, reported by the origin and optional, so an app that prefers its own column carries on as before.

It pays twice. The next write knows whether to create or update without you passing anything in:

$id = $this->source->externalId();
Enter fullscreen mode Exit fullscreen mode

And an id coming the other way, in a webhook or a reconciliation listing, finds what it belongs to:

$property = SourceRecord::where('name', 'portal')
    ->where('external_id', $id)
    ->first()?->sourceable;
Enter fullscreen mode Exit fullscreen mode

Arguments come from the record

The map in arguments() says which attribute feeds each argument. The package resolves it before the origin sees anything:

protected function arguments(): array
{
    return ['city_id' => 'external_id'];
}

// the origin receives ['city_id' => 'city-42']
Enter fullscreen mode Exit fullscreen mode

You can also pass them at call time, and those win:

$city->source('weather', ['city_id' => 'custom_id'])->fetch();
Enter fullscreen mode Exit fullscreen mode

An argument can point at another source

An argument does not have to be a value. It can point at a model, or at another source, and then it is a pointer instead of a copy:

$scraped = $article->source('scraped')->fetch();

$article->source('translation')
    ->useArgument('scraped', $scraped)
    ->fetch();
Enter fullscreen mode Exit fullscreen mode

The second origin receives the first one's payload as it is when it reads it. That is a pipeline: scrape into one source, derive from it in another, and nothing copies data from one row to the next. Each row keeps its own signature, so you know whether the scrape changed before paying for the translation again.

Variants, when one source has modes

A property is published for sale and for rent. Each one is a separate listing on the portal side, with its own id. Weather has current and forecast.

Same shape, different mode:

$property->source('portal')->setVariant('sale')->save();
$property->source('portal')->setVariant('rent')->save();
Enter fullscreen mode Exit fullscreen mode

Each variant is cached apart, keyed by sourceable, name and variant. The argument map can point at a different column per variant, which is how two listings on the same portal keep their own external ids.

When the payload arrives before the model

Scrapers run the other way round from APIs. You do not have a record and ask about it. You read a listing, keep what you found, and only then decide what it becomes.

So a source does not need a model. An external id is identity enough:

$scraped = ListingSource::make()->setExternalId($link)->fetch();

$property = Property::create(['reference' => $scraped->reference]);

$scraped->attachTo($property);
Enter fullscreen mode Exit fullscreen mode

SourceRecord::unattached() is what is still waiting for one. Attaching also re-keys the row, because a source's name is how its owner calls it, and that is what makes $property->source('portal') find it from then on.

The credentials belong to the integration

This is the part I would defend hardest, and the reason the package ships no config file.

In a multi-tenant app the API key is not yours. Every tenant connects its own account on the external service, and in some of them each branch carries its own feed code on top. The key lives in a row next to the tenant that owns it.

An env('PROVIDER_API_KEY') has no answer for that. A config block inside the package is a place where that data is never going to be.

So the origin resolves its own settings in one method, and you override it:

protected function config(?string $key = null, mixed $default = null): mixed
{
    $credentials = $this->integration->credentials ?? [];

    return $key ? ($credentials[$key] ?? $default) : $credentials;
}
Enter fullscreen mode Exit fullscreen mode

Leave it alone and it reads larasources.origins.{alias} from a config of your own, which is all a single tenant app with a static key needs.

timeout and retry go through that same method, so they are per integration too. A flaky portal gets three attempts, a strict one gets a shorter timeout, and there is no global setting pretending every origin speaks HTTP.

Bundled origins

Origin          // the base: fetch, save, delete, config, http
RemoteOrigin    // generic REST client
AgentOrigin     // agent-style integrations
ScraperOrigin   // HTML, with a getHtml() helper
Enter fullscreen mode Exit fullscreen mode

ScraperOrigin is the one that surprises people. If the external source is a page and not an API, the Source still describes it as a typed resource with casts and accessors, and the scraping stays inside the Origin.

Testing

Mock the source on the model and the origin is never reached:

$mock = (new WeatherSource())->fill(['temperature' => 22.5, 'humidity' => 60]);

$city->mockSource(WeatherSource::class, $mock);

$city->source('weather')->temperature;   // 22.5, no HTTP
Enter fullscreen mode Exit fullscreen mode

Wrapping up

The remote resource declared once as a typed class. A client that only knows the protocol. A cache in a table of your own that a live read keeps honest, and that never gets ahead of the service. Arguments resolved from the record, or pointing at another source when one feeds the next. Variants for the modes the other side insists on, their own id for the thing when they give you one, and credentials that follow the integration instead of the deployment.

Your integration layer keeps what is actually yours, which is their payload and your mapping, and stops rewriting the rest on every provider.

It needs PHP 8.4+ and Laravel 12+.

👉 Source: github.com/edulazaro/larasources
👉 Packagist: packagist.org/packages/edulazaro/larasources
👉 Documentation: edulazaro.com/portfolio/larasources

Top comments (0)