As relações polimórficas permitem uma flexibilidade bastante útil em casos específicos. Neste artigo vemos como carregar relações descendentes de modelos com relações polimórficas com Eager Loading com o ORM Eloquent, usado por omissão no Laravel.

As relações polimórficas são úteis quando alguma entidade é comum por diversos modelos ou quando um determinado modelo pode desdobrar-se em várias coisas distintas, como por exemplo

  1. Temos um modelo que pode ser relacionado com vários outros modelos, como apresentado na documentação do Laravel, onde uma Imagem pode ser aplicada tanto num Post como num Utilizador
  2. Ou temos um modelo pode ter uma relação que corresponda a vários modelos

É este último caso que vou detalhar e tudo surgiu porque há algum tempo escrevi uma package para Laravel, semelhante à league/fractal e à spatie/laravel-query-builder. O objetivo desta package é adicionar funcionalidades a REST APIs que, com determinados parâmetros na query string, permitem devolver dados adicionais ou filtrados.

Abaixo apresento um exemplo de um request, exemplificativo do funcionamento da package, que deve obter os dados do utilizador com id = 1 e incluir todos os seus posts e comentários desses posts:

/users/1?include=posts.comments

{
  "id": 1,
  "name": "Luís Cruz",
  "posts": {
    "data": [
      0 => {
        "id": 123,
        "title": "Carregar relações descendentes de MorphTo com Eager Load em Eloquent",
        "url": "carregar-relacoes-descendentes-morphto-com-eager-load-eloquent-laravel",
        "comments": {
          "data": [
            0 => {
              "id": 70,
              "subject": "Great post",
              "content": "Thank you for the information provided"
            }
          ]
        }
      }
    ]
  }
}

Isto funciona muito bem com relações “normais”, do tipo BelongsTo, HasMany, HasOne, etc. Contudo, em relações polimórficas do tipo MorphTo a coisa complica-se, essencialmente devido ao Eager Loading.

Vamos a um caso específico, em que existem os seguintes modelos:

  • Installment: Modelo que regista informações genéricas sobre as prestações de uma qualquer dívida (por exemplo, crédito à habitação)
  • InstallmentCc: Modelo que regista a informação do pagamento da prestação com cartão de crédito, relacionado com o Installment
  • InstallmentBt: Modelo que regista a informação do pagamento da prestação com transferência bancária, relacionado com o Installment
  • InstallmentBtInfo: Modelo relacionado com o InstallmentBt com mais informações sobre a transferência bancária

Em termos de dados, seria qualquer coisa como:

installments
    id - integer
    due_date - datetime
    methodable_id - int
    methodable_type - string

installments_ccs
    id - integer
    token - string

installments_bts
    id - integer
    iban - string
    swift - string

installments_bts_infos
    id - integer
    subject - string
    info - string

E a estrutura dos Modelos Eloquent será assim:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Installment extends Model
{
    /**
     * Get additional information about the installment, based on the payment method
     */
    public function methodable()
    {
        return $this->morphTo();
    }
}

class InstallmentCc extends Model
{
}

class InstallmentBt extends Model
{
    public function info()
    {
        return this->hasMany(InstallmentBtInfo::class);
    }
}

class InstallmentBtInfo extends Model
{
}

A informação das prestações é registada parcialmente no modelo Installment (informação mais genérica) e num dos modelos InstallmentCc ou InstallmentBt, mediante o tipo de pagamento. Ou seja, temos uma relação polimórfica chamada methodable no modelo Installment que irá devolver uma instância do modelo InstallmentCc ou InstallmentBt, mediante o tipo de pagamento. Além disto, o modelo específico InstallmentBt tem uma relação hasMany para o InstallmentBtInfo.

Para carregar as relações através de Eager Loading usamos o with() do Eloquent Builder. Então, caso queiramos obter toda a informação de todas as prestações, o request e a resposta deveriam ser:

/installments?include=methodable.info

[
    0 => {
        "id": 1,
        "due_date": "2019-05-30T23:59:59",
        "methodable_id": 1,
        "methodable_type": "App\Models\InstallmentCc",
        "methodable": {
            "id": 1,
            "token": "123456789"
        }
    },
    1 => {
        "id": 2,
        "due_date": "2019-06-30T23:59:59",
        "payed_at": "2019-06-21T08:43:54",
        "methodable_id": 1,
        "methodable_type": "App\Models\InstallmentBt",
        "methodable": {
            "id": 1,
            "iban": "PT15154823651254789625417",
            "swift": "BWF12487",
            "info": {
                "data": [
                    0 => {
                        "subject": "Bank info: Insufficient Funds",
                        "info": "Code 0980: bank account with insufficient funds",
                        "updated_at": "2019-06-20T15:01:36"
                    },
                    0 => {
                        "subject": "Bank info: Insufficient Funds",
                        "info": "Code 0980: bank account with insufficient funds",
                        "updated_at": "2019-06-21T08:43:12"
                    }
                ]
            }
        }
    }
]

A partir do Laravel 5.8.22 (12/06/2019) passou a estar disponível o método morphWith e a query com Eager Loading seria semelhante a:

Installment:with('methodable', function (MorphTo $morphTo) {
    $morphTo->morphWith([InstallmentBt::class => ['info']]);
})->get();

Por palavras, este código quer dizer Para todos os Installments, carrega a relação methodable. Para essas, caso o modelo relacionado seja InstallmentBt, carrega também a relação info.

Neste caso, sabemos exatamente o que queremos obter. No entanto, numa package, em que tudo deve ser devidamente dinâmico, fica mais complicado saber isto. Porquê? Bem, no caso desta package, para se processar a query string e verificar se as relações existem efetivamente no modelo pedido, é usado o método getRelated() sobre a relação.

Por exemplo, o código seguinte devolverá App\Models\InstallmentBtInfo porque é o modelo relacionado com a relação info do modelo InstallmentBt.

$installmentBt = new InstallmentBt();
dd(get_class($installmentBt->info->getRelated()));

A grande dificuldade aqui situa-se na relação polimórfica, porque o seguinte código não devolve o que eventualmente se poderia esperar:

$installment = new Installment();
dd(get_class($installment->methodable->getRelated()));

Neste caso, a classe devolvida é o próprio App\Models\Installment. Uma vez que a relação é polimórfica, é normal que o Eloquent tenha dificuldades em dizer qual é o modelo relacionado antes de executar a query.

Por esse motivo, e voltando ao request /installments?include=methodable.info, não nos é possível saber se o modelo resultante da relação methodable tem uma relação info. Aliás, para o Eloquent essa relação não existe, porque a relação methodable devolverá sempre o próprio modelo.

A abordagem correta para esta situação é só uma, passar a ignorar o info no request que resulta em /installments?include=methodable e alterar a relação methodable do Installment de forma a incluir aí o morphWith:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Installment extends Model
{
    /**
    * Get additional information about the installment, based on the payment method
    */
    public function methodable()
    {
        return $this->morphTo()->morphWith([
            InstallmentBt::class => ['info']
        ]);
    }
}

Desta forma sempre que for incluída a relação polimórfica as relações descendentes / filhas também serão incluídas.

Imagem do artigo disponibilizada por Background vector created by vector_corp - www.freepik.com