Contexto
En un negocio de compraventa de vehículos, lo más importante es conectar a los posibles clientes con los autos que se ajusten a sus preferencias. Por eso, en un sistema que gestiona este tipo de negocios, facilitar este proceso puede convertirse en un valor agregado clave.
Inicialmente, el sistema solo registraba los datos de contacto de los posibles clientes para enviar un boletín semanal con los nuevos vehículos del inventario. Sin embargo, inspirado en los filtros de búsqueda, decidí implementar sugerencias personalizadas recolectando algunos datos adicionales que me permitieran crear un vínculo entre los leads y los vehículos disponibles.
¿Como funciona?
Es un sistema básico, pero eficiente. A diferencia de mi primera versión, que realizaba una consulta compleja por cada lead para obtener los posibles vehículos, esta nueva implementación realiza una sincronización que funciona de la siguiente forma:
Cada vez que se registra un nuevo lead, el sistema toma sus preferencias —como marca, año o kilometraje de vehículo— y realiza una búsqueda en el inventario de autos disponibles. Si encuentra coincidencias, las guarda como sugerencias asociadas a ese lead.
Del mismo modo, cuando se agrega un nuevo vehículo, el sistema busca automáticamente todos los leads que podrían estar interesados según sus preferencias y los vincula con ese vehículo.
Además, cuando el estado de un vehículo cambia a 'vendido', el sistema elimina automáticamente todas sus asociaciones con leads, asegurando que ya no aparezca como sugerencia.
Este proceso mantiene las recomendaciones actualizadas en ambas direcciones, evita mostrar autos ya no disponibles y mejora la relevancia de las sugerencias para cada cliente.
Implementación
La lógica detrás del emparejamiento no es compleja, pero sí requiere organización. Laravel me permitió estructurar todo de forma clara usando eventos, modelos y relaciones. A continuación, explico cómo abordé cada parte del proceso.
Models
Cada modelo cuenta con observadores que se encargan de realizar las operaciones de sincronización. En el caso de los leads, estos almacenan rangos de características como preferencias —por ejemplo, año mínimo y máximo, tipo de combustible, tipo de transmisión, entre otros— que se usan para buscar coincidencias con los vehículos disponibles.
use App\Observers\LeadObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[ObservedBy(LeadObserver::class)]
class Lead extends Model
{
/** @use HasFactory<\Database\Factories\LeadFactory> */
use HasFactory;
protected $fillable = [
'name',
'email',
'phone',
'price_min',
'price_max',
'mileage_min',
'mileage_max',
'year_min',
'year_max',
'last_email_sent_at',
'lead_status_id',
];
}
namespace App\Models;
use App\Observers\VehicleObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[ObservedBy(VehicleObserver::class)]
class Vehicle extends Model
{
/** @use HasFactory<\Database\Factories\VehicleFactory> */
use HasFactory;
protected $fillable = [
'model',
'description',
'year',
'mileage',
'engine_size',
'taxes_paid',
'price',
'slug',
'image_id',
'fuel_id',
'brand_id',
'color_id',
'insurance_id',
'drivetrain_id',
'transmission_id',
'publication_status_id',
'vehicle_status_id',
'sold_at',
'published_at',
];
}
Observers
Los observadores escuchan los eventos de creación y actualización tanto de vehículos como de leads, y al activarse, ejecutan el evento responsable de realizar la sincronización entre ambas entidades.
namespace App\Observers;
use App\Events\SyncLeadsAndMatchingVehicles;
use App\Models\Vehicle;
class VehicleObserver
{
/**
* Handle the Vehicle "created" event.
*/
public function created(Vehicle $vehicle): void
{
event(new SyncLeadsAndMatchingVehicles($vehicle));
}
/**
* Handle the Vehicle "updated" event.
*/
public function updated(Vehicle $vehicle): void
{
event(new SyncLeadsAndMatchingVehicles($vehicle));
}
}
Listeners
Este listener se encarga de redirigir al job correspondiente según el modelo que se desea sincronizar, ya sea un lead o un vehículo.
namespace App\Listeners;
use App\Events\SyncLeadsAndMatchingVehicles;
use App\Jobs\SyncLeadMatchesWithVehiclesJob;
use App\Jobs\SyncVehicleMatchesWithLeadsJob;
class HandleSyncLeadsAndMatchingVehicles
{
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(SyncLeadsAndMatchingVehicles $event): void
{
$model = $event->model;
if ($model instanceof \App\Models\Lead) {
dispatch(new SyncLeadMatchesWithVehiclesJob($model));
} elseif ($model instanceof \App\Models\Vehicle) {
dispatch(new SyncVehicleMatchesWithLeadsJob($model));
}
}
}
Jobs
Este job se encarga de llamar al servicio que ejecuta la lógica detrás de la sincronización, aislando así la responsabilidad y manteniendo el código organizado y reutilizable.
namespace App\Jobs;
use App\Models\Lead;
use App\Services\MatchService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SyncLeadMatchesWithVehiclesJob implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct(
public Lead $lead
)
{
//
}
/**
* Execute the job.
*/
public function handle(MatchService $matchService): void
{
$leadId = $this->lead->id;
$matchService->matchLeadWithVehicles($leadId);
}
}
Services
En MatchService es donde ocurre el emparejamiento como tal. Este servicio obtiene al lead y sus preferencias a través del LeadService, y luego las envía al MatchRepository, que se encarga de obtener los IDs de los vehículos compatibles y sincronizarlos con el lead.
use App\Repositories\Contracts\MatchRepositoryInterface;
class MatchService
{
public function __construct(
private VehicleService $vehicleService,
private LeadService $leadService,
private MatchRepositoryInterface $matchRepository
) {}
public function matchLeadWithVehicles(int $leadId)
{
$lead = $this->leadService->getLead($leadId);
$preferences = $lead->getPreferencesAttribute();
$matches = $this->matchRepository->findVehiclesByLeadPreferences($preferences);
$lead->matches()->sync($matches);
}
}
Repositories
La clase MatchRepository es la encargada de ejecutar las consultas dinámicas basadas en las preferencias del lead. Utiliza un conjunto de filtros condicionales (when) para construir la consulta de forma flexible según los datos disponibles.
Por ejemplo, si el lead especificó un rango de precios, kilometraje o año, esos filtros se aplican mediante whereBetween. Para opciones como combustible, marca o transmisión, se usa whereIn con los valores seleccionados. Además, solo se consideran vehículos que estén publicados, usando un estado predeterminado (PUBLISHED_STATUS_ID = 1).
El método findVehiclesByLeadPreferences devuelve un array con los IDs de los vehículos que cumplen con los criterios definidos, lo que permite sincronizarlos fácilmente con el lead.
namespace App\Repositories\Eloquent;
use App\Models\Lead;
use App\Models\Vehicle;
use App\Repositories\Contracts\MatchRepositoryInterface;
class MatchRepository implements MatchRepositoryInterface
{
private const PUBLISHED_STATUS_ID = 1;
public function findVehiclesByLeadPreferences(array $preferences)
{
$query = Vehicle::query();
$query->where('vehicle_status_id', '=', $this::PUBLISHED_STATUS_ID);
$query->when($preferences['price']['min'] && $preferences['price']['max'], function ($query) use ($preferences) {
$query->whereBetween('price', [$preferences['price']['min'], $preferences['price']['max']]);
});
$query->when($preferences['mileage']['min'] && $preferences['mileage']['max'], function ($query) use ($preferences) {
$query->whereBetween('mileage', [$preferences['mileage']['min'], $preferences['mileage']['max']]);
});
$query->when($preferences['year']['min'] && $preferences['year']['max'], function ($query) use ($preferences) {
$query->whereBetween('year', [$preferences['year']['min'], $preferences['year']['max']]);
});
$query->when($preferences['fuels'], function ($query) use ($preferences) {
$query->whereIn('fuel_id', $preferences['fuels']);
});
$query->when($preferences['brands'], function ($query) use ($preferences) {
$query->whereIn('brand_id', $preferences['brands']);
});
$query->when($preferences['transmissions'], function ($query) use ($preferences) {
$query->whereIn('transmission_id', $preferences['transmissions']);
});
$query->when($preferences['drivetrains'], function ($query) use ($preferences) {
$query->whereIn('drivetrain_id', $preferences['drivetrains']);
});
return $query->pluck('id')->toArray();
}
}
Gracias a la estructura modular de Laravel, este sistema de emparejamiento resulta fácil de mantener, escalar y adaptar. Además de funcionar de forma automática mediante eventos, también permite ejecutar el emparejamiento de forma manual desde la API si se requiere más control.
Si deseas ver el sistema en acción, he preparado un formulario donde puedes registrar tus preferencias y recibir sugerencias de vehículos que podrían interesarte: