Origins
The Origin is the client. It knows the protocol, the endpoints and the credentials, and nothing about the shape of your data, which is the Source's job.
namespace App\Origins; use EduLazaro\Larasources\Origins\Origin; use EduLazaro\Larasources\Exceptions\OriginException; use Illuminate\Http\Client\RequestException; class ListingOrigin extends Origin { public static function getAlias(): string { return 'listing'; } public function fetch(array $arguments = []): array { return $this->http() ->get("{$this->baseUrl()}/listings/{$arguments['id']}") ->throw() ->json(); } public function save(array $data): array { try { return $this->http() ->post("{$this->baseUrl()}/listings", $data) ->throw() ->json(); } catch (RequestException $e) { throw new OriginException('The provider refused the listing: ' . $e->getMessage()); } } public function delete(): bool { $this->http() ->delete("{$this->baseUrl()}/listings/{$this->source->externalId()}") ->throw(); return true; } }
fetch() is the only abstract method. save() and delete() throw a clear "not supported" by default, so a source you only read needs neither.
The alias identifies the integration. It is what config() puts in front of a key, and it is stored on every row, so you can always tell which client wrote what.
Failure is an exception
Returning means the origin took the data. The source writes its record right after the call returns, so a failure that returns instead of throwing is stored as if it had worked, and since a stored row is trusted, nothing will ever go looking for it again.
Whether a service accepted something is only knowable inside the origin, which is the one side that understands that API. One provider answers 200 with an error object, another uses 422, another queues the payload and tells you nothing. So the package never reads a response to decide, and the signal is the one thing it cannot misread: control flow.
Throw OriginException, or anything extending it. A domain exception of your own is often worth it:
class ListingRefusedException extends OriginException { public function __construct(public readonly int $statusCode, string $message) { parent::__construct($message); } }
A job catching that can tell a 4xx it should not retry from a 5xx it should.
The HTTP client
http() returns a Laravel PendingRequest with the timeout and the retries of this integration already applied:
'timeout' => 15, 'retry' => ['attempts' => 3, 'delay' => 1000],
Without them the client makes a single attempt with a thirty second timeout. Nothing forces you to use it: an origin that talks to an SDK, a queue, a filesystem or a scraping library is still an origin, and fetch() returning an array is the whole contract.
Being configured
isConfigured() answers whether the origin has what it is about to use, and it is true unless an origin says otherwise:
public function isConfigured(): bool { return (bool) $this->config('api_key'); }
Checking for the existence of a config block instead would be wrong for the common case, which is credentials sitting in a row next to the tenant that owns them. Call it before sending anything when a missing credential should read as a configuration error rather than a provider error.
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 application 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, and 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; }
Leave it alone and it reads larasources.origins.{alias} from a config of your own, which is all a single tenant application with a static key needs:
// config/larasources.php, if you want this convention return [ 'origins' => [ 'listing' => [ 'api_key' => env('LISTING_API_KEY'), 'sandbox' => env('LISTING_SANDBOX', false), 'timeout' => 15, 'retry' => ['attempts' => 3, 'delay' => 1000], ], ], ];
Resolving per record
An origin is constructed with the source, which carries the model, so which integration to use can be worked out from the thing being published:
public function __construct(Source $source) { parent::__construct($source); $this->integration = Integration::resolveFor($source->getSourceable()); }
That is how a branch with its own account overrides the organisation's, and how a tenant without one is simply not published. timeout and retry go through the same method, so a flaky provider gets three attempts and a strict one gets a shorter timeout, per integration rather than per deployment.
If you are coming from an older version
The settings reader used to be called getConfig(). It is config() now, and an origin that still declares the old name fails at construction with a message telling you to rename it. That is deliberate: the package calls config(), so an old override would never run and the credentials would come back empty with nothing in a log to explain it. A loud failure beats a silent integration.
Operations beyond the three
origin() is public, so an origin's own methods are callable without ceremony:
$quota = $property->source('listing')->origin()->fetchQuota();
And when the answer is the source's new state, name it on the source and write it through with the two calls fetch() is made of:
public function regenerateFromBriefing(string $briefing): static { $this->fill($this->origin()->generateFromBriefing($briefing)); return $this->persist(); }
There is deliberately no run('someMethod') dispatcher. It would trade methods for strings, lose every bit of autocompletion and static analysis, and let a caller fill a source from the return value of anything at all.