He joined forty minutes ago. No job, forty dollars in the bank, a name that is four consonants and a number, and right now he is doing a hundred and forty down the Del Perro Freeway in something Italian with scissor doors. Nobody sold it to him. He found the one resource on your server that still lets a FiveM vehicle spawn start on the client, asked it politely, and it said yes.
Every FiveM vehicle spawn is a small decision about who your economy belongs to, and most servers make that decision by accident. This post is about making it on purpose. Gating a dealership by vehicle class and price tier, handing out job vehicles and emergency fleet without handing them to everyone, giving donors something real that is not an advantage, checking all of it in a place the player cannot reach, and the part almost nobody writes down, which is what to do about your own admins.
Why a FiveM vehicle spawn belongs on the server
A client that can ask for a car can ask for any car. That is the whole problem. It does not get more complicated the longer you look at it, and every rule below is a consequence.
When your dealership triggers a client event that spawns the car locally, the model name is a string travelling over the wire, and the person on the other end owns both the string and the code that sent it. An injected menu does not need to defeat your dealership. It only needs to fire the same event with a different argument, and your price check ran on their machine.
Move creation to the server and the shape changes. The client sends an intent, the server decides, the server spawns. FiveM gives you CreateVehicle as a server native under OneSync, so the transaction lives somewhere the player has no handle on:
-- server/dealership.lua
RegisterNetEvent('dealer:buyVehicle', function(modelName)
local src = source
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return end
local catalogue = QBCore.Shared.Vehicles[modelName]
if not catalogue then
Log(src, 'unknown_model', tostring(modelName))
return
end
if not IsAllowed(src, Player, catalogue) then
Log(src, 'spawn_denied', modelName)
return
end
if Player.PlayerData.money.bank < catalogue.price then return end
Player.Functions.RemoveMoney('bank', catalogue.price, 'vehicle-purchase')
local coords = GetEntityCoords(GetPlayerPed(src))
local veh = CreateVehicle(GetHashKey(modelName), coords.x, coords.y, coords.z, 0.0, true, true)
while not DoesEntityExist(veh) do Wait(0) end
TriggerClientEvent('dealer:finishDelivery', src, NetworkGetNetworkIdFromEntity(veh))
end)
Three things matter there. The requested model is looked up in your own catalogue, so anything not on the list dies immediately. Permission is checked against the player the server knows about, not the one the client claims to be. And the entity is created by the server, so a rejected request produces nothing at all. The client still finishes the plates and the seating, because that is client work, but it is finishing something the server already agreed to.
Closing the back door you forgot about
Server side spawning only helps for the resources you wrote. The one you bought in 2023 is still doing it the old way, and so is the admin menu somebody installed for a weekend and never removed.
The catch-all is entityCreating, a server event that fires before a networked entity comes into existence and can be cancelled:
AddEventHandler('entityCreating', function(entity)
if GetEntityType(entity) ~= 2 then return end
local creator = source -- 0 when our own server code made it
if creator == 0 then return end
if not IsPlayerAceAllowed(creator, 'fleet.freespawn') then
CancelEvent()
Log(creator, 'client_spawn_blocked', GetEntityModel(entity))
end
end)
Run it in log only mode first. The list of resources spawning from the client is usually longer and more embarrassing than anyone expects, so give it a week, fix what it finds, then turn the cancel on and your vehicle whitelist becomes a whitelist rather than a suggestion.
Gating a dealership by vehicle class and price tier
GTA V sorts every vehicle into one of twenty-two classes, and those numbers are the cheapest structure you will ever get for free. Class 4 is muscle, 5 is sports classics, 6 is sports, 7 is super, 8 is motorcycles, 9 is off-road, 15 is helicopters, 18 is emergency, 19 is military. GetVehicleClass is a client native, so store the class alongside the price in your own vehicles table. The server should never need to see the vehicle to know what it is.
Class restrictions built on top of that are simple to express and hard to argue with:
local CLASS_RULES = {
[4] = { hours = 8 }, -- muscle
[6] = { hours = 25 }, -- sports
[7] = { hours = 60, licence = 'super' }, -- super
[8] = { licence = 'bike' }, -- motorcycles
[15] = { job = { 'police', 'ambulance' } }, -- helicopters
[18] = { job = { 'police', 'ambulance' } }, -- emergency
[19] = { ace = 'fleet.military' }, -- military
}
Price is the other axis. Class says what kind of driver you are. Price says how long you have been at it, and gating on both is what makes a dealership read like a career instead of a vending machine.
The requirement that works best is not playtime, though playtime is easiest to implement. It is owning the step below. Make the sports tier need a muscle car in the garage first, and you get people who spend a month in a Dominator and end up genuinely attached to it. Servers with a real muscle and classic car culture almost always got there by making the cheap end of the catalogue the only road to the expensive end.
Keep the failure message honest, too. "You need 25 hours and a class 4 vehicle" turns a refusal into a goal. A silent nothing turns it into a bug report.
Job vehicles and emergency fleet access without leaks
Job vehicles are where most servers accidentally build the leak. The garage checks the job on the client, which is fine for hiding the menu and useless for anything else, and the actual spawn trusts whatever arrives.
Do the check where the job lives, which in QBCore means the player object the server holds: Player.PlayerData.job.name, the grade at Player.PlayerData.job.grade.level, and duty state at Player.PlayerData.job.onduty. In ESX the equivalents hang off xPlayer from ESX.GetPlayerFromId(src), with xPlayer.job.name and xPlayer.job.grade. Same idea, different spelling, and neither of them is readable by the person asking for a helicopter.
Grade matters as much as job. A rookie officer and a chief both have job.name == 'police', and only one of them should be taking the unmarked pursuit car out. Store a minimum grade per model and the hierarchy expresses itself without an extra command.
A few habits keep the emergency fleet out of private garages:
- Give fleet vehicles a plate prefix and never write them to
player_vehiclesorowned_vehicles. What cannot be inserted into the ownership table cannot be saved into somebody's collection by an obliging garage script. - Delete them on duty end and on disconnect. A police Buffalo parked in an alley for nine hours is a vehicle somebody will eventually get into.
- Check the job again when keys are handed out, not only when the car spawns.
That last point matters, because keys and garages already decide who can start a car long after the dealership stopped paying attention. Spawn permission and drive permission are different questions, and a server that only answers the first has a fleet that walks off.
Donor cars that are not pay to win
Selling vehicles is fine. Selling a faster vehicle is where servers lose the room.
The line is drawn in handling.meta, not in the store description. If the donor car has more grip, more power or better braking than anything a player can earn, you have sold a stat, and everyone will work that out within a week of the first street race. Nobody minds a paid car that looks incredible. Everybody minds losing to one.
What sells well and costs nothing in fairness: a model that is not in the public catalogue, a livery set nobody else has, an early look at next season's stock. Keep the price gate and the class restrictions on it. A donor supercar that still needs the same sixty hours as everyone else's is a cosmetic, and cosmetics are the only thing you can safely sell twice.
Time-limited access does the same job even better. Something that rotates, that people can miss, and that comes back around, which is exactly the machinery behind a battle pass and seasonal progression. A car people chased for six weeks is worth more to them than the same car handed over at checkout, and it does not put a single extra horsepower on the road.
Admin spawns, and the log that saves your Sunday
Your economy does not usually get wrecked by a cheater. It gets wrecked by a trusted staff member who spawned a Zentorno for a friend, twice, in March, and nobody noticed until June.
Route admin spawning through the same server function as everything else with a bypass flag, so it obeys the same catalogue and produces the same log line. Restrict the command with an ace rather than a name list, because RegisterCommand('car', handler, true) checks command.car for you and you can grant it in server.cfg where it is visible:
RegisterCommand('car', function(src, args)
if src == 0 then return end
local model = args[1]
if not model then return end
local veh = SpawnFor(src, model, { bypass = true, temporary = true })
if veh then
Log(src, 'admin_spawn', model, GetPlayerIdentifierByType(src, 'license'))
end
end, true)
Two properties make the difference. Admin spawned cars are temporary, so they never touch the ownership table and they get deleted on disconnect, which stops them becoming somebody's daily driver. And every one of them lands in a Discord webhook with the licence identifier, the model and the timestamp, in a channel your staff can see. A visible log stops a slow habit from forming before it costs you anything.
Read that channel once a month. If the same admin has spawned eleven supers and none of them were for an event, you have found your inflation before your players did.
Keeping supercars rare enough to still mean something
Every FiveM vehicle spawn rule above is enforcement. Scarcity is a supply decision, and no amount of enforcement saves you from a dealership with three hundred cars in it.
Stock counts are the tool people skip. A dealer that holds two of a model until somebody buys one is a completely different economy from one with infinite stock, and it costs you a single integer column. Auction the rarest things rather than listing them, and let a wrecked super stay expensive to recover, so ownership carries a running cost.
The number that matters is not what a supercar costs. It is how many are on the street on a Friday night. If the answer is forty, the price is wrong no matter what the sign says.
The short version
Spawn on the server, because a client that can ask for a car can ask for any car, and check the model against your own catalogue before anything exists. Use classes and price tiers together so the cheap end of the fleet is the road to the expensive end. Check jobs and grades against the player object the server holds, and keep fleet vehicles out of the ownership tables. Sell looks and access, never grip. Log every admin spawn where your staff can see it. Then keep the supercar count low enough that owning one is still a story.
Do all of that and your newest player is still out there driving badly. He is doing it in a Blista he did not choose, he is furious, and in six weeks he will have earned something he point blank refuses to sell.



