edulazaro / larasources
Integrate external data sources into Laravel models with caching, retry and rate-limiting. Sources are model-like classes; Origins are pluggable API clients.
Requires
- php: >=8.4
- illuminate/database: >=9.0
- illuminate/http: >=9.0
- illuminate/support: >=9.0
Requires (Dev)
- orchestra/testbench: ^7.0|^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^9.0|^10.0|^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Larasources - External data sources for Laravel models
A Laravel package for integrating external data sources into your models, with a write-through cache in your own database. Work with external APIs using model-like abstractions, without forcing those APIs to live in your own tables.
Why
Larasources lets your Eloquent models pull and push data from external services through a typed, declarative Source API. Each source declares its fillable fields, casts, mappings, and origin (the API client). Your domain model stays clean, the integration layer stays separated, and the cached external state lives in a single dedicated table.
Features
- Model-like Sources: define external resources as classes with
fillable,casts, accessors, and arguments - Origins: pluggable API clients (
fetch,save,delete) decoupled from the data shape - Built-in caching through the
sourcestable (SourceRecord) - Variants and arguments to handle multiple operations or per-call parameters
- No package configuration: credentials, timeout and retries belong to each origin
- Mockable for tests via
mockSource()
Requirements
- PHP
>=8.4(any future version included) - Laravel
>=12.0(any future version included)
Continuous integration runs the suite against every supported PHP and Laravel combination, plus PHP nightly as an early warning.
Installation
composer require edulazaro/larasources
Run the migrations:
php artisan migrate
Configuration
The package has no configuration file and no environment variables of its own. What an
origin needs is the integration's own, and config() is where it resolves it.
config() works like Laravel's config() helper without the prefix: the origin puts
larasources.origins.{alias} in front and the caller only names the key, so static
credentials can live in a config of your own:
// config/larasources.php, if you want this convention 'origins' => [ 'my_provider' => [ 'api_key' => env('MY_PROVIDER_API_KEY'), 'sandbox' => env('MY_PROVIDER_SANDBOX', false), 'timeout' => 15, 'retry' => ['attempts' => 3, 'delay' => 1000], ], ],
When the credentials are not static, which is the usual case in multi-tenant applications,
override config() and read them from wherever they live:
class MyProviderOrigin extends Origin { protected function config(?string $key = null, mixed $default = null): mixed { $credentials = $this->integration->credentials ?? []; return $key ? ($credentials[$key] ?? $default) : $credentials; } }
timeout and retry go through the same method, so an origin that builds its requests
with $this->http() gets the timeout and the retries of that integration. Without them the
client makes a single attempt with the origin's $timeout property.
A Source has the same accessor over its own block, larasources.sources.{name}, where the
name is the key it is mapped under on the model:
'sources' => [ 'weather' => ['units' => 'metric'], ],
$this->config('units'); // inside the source $source->config('units'); // from outside
Override it the same way when those settings are not static, for instance to serve them from the origin's integration.
Usage
1. Add the HasSources trait to your model
use Illuminate\Database\Eloquent\Model; use EduLazaro\Larasources\Concerns\HasSources; use App\Sources\WeatherSource; class City extends Model { use HasSources; protected function sources(): array { return [ 'weather' => WeatherSource::class, ]; } }
The trait already declares a $sources property, and PHP refuses to compose a class that
redeclares it with a different default, so declare the mapping with the sources() method.
Assigning $this->sources from the constructor also works and takes lower precedence.
2. Read and write through the source
$city = City::find(1); // Access external data (autoloaded from cache or fetched on miss) $weather = $city->source('weather'); echo $weather->temperature; echo $weather->humidity; // Push data to the external API and persist locally $city->source('weather')->save(); // Read live from the API and refresh the cached record $fresh = $city->source('weather')->fetch(); // Delete remote and clear cache $city->source('weather')->delete();
3. Define a Source
namespace App\Sources; use EduLazaro\Larasources\Source; use EduLazaro\Larasources\Attributes\UsesOrigin; use App\Origins\MyProviderOrigin; #[UsesOrigin(MyProviderOrigin::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', // maps to $city->external_id ]; } public function getFeelsLikeAttribute(): float { return $this->temperature - ($this->humidity / 10); } }
4. Define an Origin (the API client)
namespace App\Origins; use EduLazaro\Larasources\Origins\Origin; use Illuminate\Support\Facades\Http; class MyProviderOrigin extends Origin { public static function getAlias(): string { return 'my_provider'; } public function fetch(array $arguments = []): array { $response = Http::withToken($this->config('api_key')) ->get('https://api.example.com/weather/' . $arguments['city_id']); return $response->json(); } public function save(array $data): array { $response = Http::withToken($this->config('api_key')) ->post('https://api.example.com/weather', $data); return $response->json(); } public function delete(): bool { return true; } }
5. Variants and arguments
Use variants to handle multiple modes per source (for example, sale vs rent for a property listing, or current vs forecast for weather):
$city->source('weather')->setVariant('forecast')->fetch(); // Pass runtime arguments $city->source('weather', ['city_id' => 'custom_id'])->fetch();
The arguments() map declares which attribute of the model feeds each argument, and
resolveArguments() turns it into values: with ['city_id' => 'external_id'] the origin
receives ['city_id' => $city->external_id], or null when the attribute is empty.
Precedence, from lowest to highest: the $arguments property, the arguments() method,
the arguments stored on the SourceRecord, and the runtime arguments passed to source().
Arguments that do not come from the model live on the record, either in its arguments
JSON column or as SourceArgument rows pointing at another model or record. They are
written by your app, not by persist(), which only refreshes the cached attributes: a
resolved value stored there would shadow the model from then on.
Caching
Sources are cached automatically in the sources table (the SourceRecord model). Each
record is keyed by (sourceable, name, variant).
Reading an attribute autoloads: it fills from the cached record when there is one, and goes
to the origin when there is not. fetch() always goes to the origin and writes the
result through to the record, so the cache is never left behind after a live read. A
source with no model attached has nothing to cache, and fetch() just fills the instance.
// Has it ever been fetched/saved? if ($source->getRecord()) { // Data is cached locally } // What the origin says right now, cached on the way out $source->fetch(); // Compare the cached picture with the origin $cached = $source->getRecord()?->attributes ?? []; $live = $source->fetch()->toArray(); // Clear the cache for this source $source->clear();
The cache does not expire on its own: once a record exists, reading attributes never calls
the origin again. Refresh it when your app decides to, with fetch().
Error handling
use EduLazaro\Larasources\Exceptions\OriginException; try { $weather = $city->source('weather')->fetch(); } catch (OriginException $e) { Log::error('Provider error: ' . $e->getMessage()); }
Testing
Mock a source so it returns a fixed instance instead of hitting the origin:
$mock = new WeatherSource(['temperature' => 22.5, 'humidity' => 60]); $city->mockSource(WeatherSource::class, $mock); $weather = $city->source('weather'); // $weather is the mocked instance
API reference
Source
fetch(): read live from the origin and refresh the cached recordsave(): push current attributes to the origin and persistsaveToOrigin(): push without persisting locallydelete(): delete remote and clear cacheclear(): clear cached record onlyorigin(): get the resolved Origin instancegetRecord(): get the underlyingSourceRecord(ornull)setVariant(string $variant): set the source's variantsetVariantArguments(array $args): pass runtime argumentsconfig(?string $key, mixed $default): this source's settings, underlarasources.sources.{name}name(): the key this source is mapped under on the model
Origin
fetch(array $arguments): arraysave(array $data): arraydelete(): boolregenerate(): arraygetAlias(): stringconfig(?string $key, mixed $default): resolve this integration's settings, underlarasources.origins.{alias}. Override it when they are not statichttp(): HTTP client with the integration'stimeoutandretry
Bundled abstract Origins
Origin: base classRemoteOrigin: generic REST client baseAgentOrigin: for agent-style integrationsScraperOrigin: for HTML scraping withgetHtml()helper
Testing
composer install
composer test
The suite runs on Testbench with an in-memory SQLite database. tests/Fixtures holds a
sourceable model, a source and two origins (one plain, one going through the package's HTTP
client) that the tests build on.
Sponsors
Larasources is supported by the following sponsors. Thank you for keeping it growing:
Author
Created by Edu Lazaro
License
Larasources is open-sourced software licensed under the MIT license.
