Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

dannydehaan's avatar

Custom relationship query

Hi All,

We're currently building an application for a client who wants automatic parcel pickup assignments based on Dutch postcodes, e.g 1018DR.

I have a model Account, in this model i have the properties id, postcode and country

id: 1. postcode: 1018DR. country: NL
id: 2. postcode: 1014BB. country: NL

I have a model Range, in this model i have the properties pickup_point_id, from and to.

pickup_point_id: 1. from: 1000AA. to:1100ZZ
pickup_point_id: 2. from: 1015AA. to:1018ZZ

Will be saved as ASCII, so 10006565 to 11009090, but the letters are for readability

I have a model PickupPoint, in this model i have the property id

id: 1
id: 2

I have a model Parcel, in this model i have the properties id, account_id, is_default and pickup_point_id. pickup_point_id is nullable. pickup_point_id will be used for overruling the ranges, in case a client calls and tell them to deliver it at pickup point 2 instead of pickup point 1.

id: 1. account_id: 1 pickup_point_id: null
id: 2. account_id: 2 pickup_point_id: 2
id: 3. account_id: 2 pickup_point_id: null

I have the following simple code:

<?php
use App\Models\Parcel;

$parcel = Parcel::find(1);
$pickup_point = $parcel->pickup_point ?? $parcel->account->pickup_point;

$parcel = Parcel::find(2);
$pickup_point = $parcel->pickup_point ?? $parcel->account->pickup_point;

$parcel = Parcel::find(3);
$pickup_point = $parcel->pickup_point ?? $parcel->account->pickup_point;
?>
  1. The pickup_point should be 2, based on the range
  2. The pickup_point should be 2, because of the assigned pickup_point
  3. The pickup_point should be 1, based on the range

For case 2, i don't see any problem, because i have a relation that has been set.

<?php
public function pickup_point(): HasOne
{
    return $this->hasOne(PickupPoint::class);
}
?>

But case one and three are giving me headaches. In account, i also want a HasOne relationship, but i don't have an ID, because of the range. I already have the query to select the pickup_point based on the postcode.

select @postcode := (
    CASE WHEN
        (UPPER(SUBSTRING(accounts.postcode, -2, 1)) BETWEEN 'A' AND 'Z')
        AND
        (UPPER(SUBSTRING(accounts.postcode, -1, 1)) BETWEEN 'A' AND 'Z')
        AND
        accounts.country = 'NL'
    THEN
        CONCAT(
            SUBSTRING(accounts.postcode, 1, 4),
            ASCII(UPPER(SUBSTRING(accounts.postcode, -2, 1))),
            ASCII(UPPER(SUBSTRING(accounts.postcode, -1, 1)))
        )
    ELSE
        accounts.postcode
    END
) AS 
    postcode,
    ranges.pickup_point_id,
    ((@postcode - ranges.from) + (ranges.to - @postcode)) AS sum,
    ranges.from,
    ranges.to
FROM 
    accounts, ranges
WHERE 
    ranges.from <= @postcode
AND 
    ranges.to >= @postcode
AND 
    ranges.country = 'NL' /* This should be accounts.country */
AND 
    accounts.id = 1 /* This should be accounts.id */
ORDER BY 
    sum

Long story short: I want the above query to be injected in the $relation->setQuery() in this piece of code in the Account model:

<?php
public function pickup_point(): HasOne
{
    $relation = $this->hasOne(PickupPoint::class)
        ->withDefault(
            PickupPoint::query()
                ->where('is_default', '=', true)
        );


    // Here should the query be "injected"
	$relation->setQuery();

    return $relation;
}
?>

And no, i cannot use $this->id and $this->country because $this is empty when eager loading.

0 likes
10 replies
dannydehaan's avatar

@vincent15000 Hi Vincent, yes, I thought about this. But I need to merge my query with the original Laravel query where it's fetching the parcel. So, for example, I query Parcel::query()->where('id', '=', 1)->with('account')->first(); the Laravel query is like the following:

SELECT p.*, a.* FROM parcels p LEFT JOIN accounts a ON p.account_id = a.id  WHERE p.id = 1;

Somewhere in this query i need to merge my query so i can use a.id and a.country, see the comments in the last 5 lines of the query.

1 like
vincent15000's avatar

@dannydehaan Well the big query in your previous comment is to retrieve a postcode, isn't it ?

Now you show me a parcel query which you didn't talk about from the beginning.

It's probably very clear for you, but I'm lost ;).

I assume that with your last query (the parcel one) you retrieve an account and you need to use the account id and country to inject those values into the previous big query to retrieve the postcode.

Can you confirm that I have understood ?

dannydehaan's avatar

@vincent15000 Yes, the big query is to retrieve the pickup_point_id from the ranges table. With that, i can fetch the pickup point, based on the postcode of the account. See codeblock 4 and 5 for the Parcel model and code.

You understood it right!

To make it more clear (or not) i have 3 scenario's

  1. Parcel has a pickup point defined (pickup_point_id) Easy HasOne
  2. Parcel doesn't have a defined pickup point, so retrieve it from the account, based on account.postcode
  3. No range found in the account model, so use ->withDefault()
1 like
vincent15000's avatar

@dannydehaan

retrieve the pickup_point_id from the ranges table ???

select @postcode := (
    	...
	)
FROM 
    accounts, postcodes
...

Where is the ranges table in your query ?

Sorry I have to go, I come back soon.

1 like
vincent15000's avatar

@dannydehaan Oh now I understand better.

I would probably do that another way using 2 queries : first of all retrieve the parcel and account and then retrieve the postcode.

vincent15000's avatar

@dannydehaan It depends on the number of ranges you need to retrieve.

Have you thought about retrieving some more datas and then filtering them by handling the collection ?

dannydehaan's avatar

@vincent15000 I just need to fetch one pickup point, the query should is updated like below.

select @postcode := (
    CASE WHEN
        (UPPER(SUBSTRING(accounts.postcode, -2, 1)) BETWEEN 'A' AND 'Z')
        AND
        (UPPER(SUBSTRING(accounts.postcode, -1, 1)) BETWEEN 'A' AND 'Z')
        AND
        accounts.country = 'NL'
    THEN
        CONCAT(
            SUBSTRING(accounts.postcode, 1, 4),
            ASCII(UPPER(SUBSTRING(accounts.postcode, -2, 1))),
            ASCII(UPPER(SUBSTRING(accounts.postcode, -1, 1)))
        )
    ELSE
        accounts.postcode
    END
),
    postcodes.pickup_point_id
FROM 
    accounts, postcodes
WHERE 
    postcodes.from <= @postcode
AND 
    postcodes.to >= @postcode
AND 
    postcodes.country = 'NL' /* This should be accounts.country */
AND 
    accounts.id = 123580 /* This should be accounts.id */
ORDER BY 
    ((@postcode - postcodes.from) + (postcodes.to - @postcode))
LIMIT 1

Based on this query, it should load a pickup point like: PickupPoint::query()->where('id', '=', 1);

Please or to participate in this conversation.