Identity
A row is identified by the model it belongs to or, when there is none yet, by the id the service gave the resource. That second half is what lets a payload exist before the thing it describes.
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, remote_uuid, whatever you named yours.
It lives on the row now. The origin reports it, and it is optional: an origin that reports none leaves it empty, and an application that would rather hold it in a column of its own carries on exactly as before.
return new OriginResult( status: OriginStatus::Saved, data: $response, externalId: $response['id'], );
Nothing is read out of the response to guess it. What an id is called in a body is that service's business, so the origin says which field it is, or says nothing.
Creating or updating
The first use is deciding which of the two a write is, without the application passing anything in:
public function save(array $data): array|OriginResult { $id = $this->source->externalId(); $response = $id ? $this->http()->put("{$this->baseUrl}/listings/{$id}", $data)->throw()->json() : $this->http()->post("{$this->baseUrl}/listings", $data)->throw()->json(); return new OriginResult(OriginStatus::Saved, data: $response, externalId: $response['id']); }
A fetch() never clears it, because reading does not report one. So once a create has happened, every later write is an update, and that survives a cache refresh.
With variants, each one keeps its own id, which is exactly right when a provider treats the sale listing and the rent listing as two resources.
Finding the local model from an id
The second use is the other direction. An id arriving in a webhook, or in a reconciliation listing from the provider, finds what it belongs to:
$record = SourceRecord::where('name', 'listing') ->where('external_id', $id) ->first(); $property = $record?->sourceable;
There is an index on (name, external_id) for exactly that. It replaces the hand-written join every integration grows: fetch their list of live resources, match it against yours, and act on what does not line up.
$theirs = collect($origin->fetchPublished())->keyBy('id'); $ours = SourceRecord::where('name', 'listing')->whereNotNull('external_id')->get()->keyBy('external_id'); $orphans = $theirs->keys()->diff($ours->keys()); // live there, unknown here $ghosts = $ours->keys()->diff($theirs->keys()); // known here, gone there
Sources with no model yet
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.
$scraped = ListingSource::make()->setExternalId($link)->fetch(); $property = Property::create(['reference' => $scraped->reference]); $scraped->attachTo($property);
make() is a declared static that returns an instance. setExternalId() is the identity, and with it a source can be stored before anything owns it. The link, their id, a hash of the page: whatever is stable and unique on their side.
What is still waiting
SourceRecord::unattached()->get();
That is the queue a derivation command works through: read the rows nobody has turned into anything yet, create the thing, attach it, done. Re-reading the same id updates its row instead of adding one, so a scraper that runs twice does not duplicate.
Attaching re-keys the row
A source's name is how its owner calls it, which is the key the model maps the class under. Without a model there is no map to ask, so the row is named after the class, or after a protected ?string $sourceName it declares. attachTo() renames it to the model's key, and that is what makes $property->source('listing') find it afterwards.
A model holds one row per source and variant, so attaching to a model that already has one throws:
Property [41] already has a `listing` source, stored as row 912.
Deciding which of the two payloads matters is yours, not ours to guess: delete the one you do not want, or attach this one to something else.
No identity, no row
With neither a model nor an external id nothing is stored:
$rates = ExchangeRatesSource::make()->fetch(); // read it, use it, forget it
So a throwaway read stays a throwaway read, and a detached source cannot leave rows behind in a test.
When this is the wrong tool
If what you are building is an inbox of thousands of scraped items with their own workflow columns, their own filtering and their own admin screen, that is a model of yours with a table of yours. The payload here is the external state of one thing, and the one thing is what your application is about.