Most FiveM job scripts ship with a config.lua that server owners are expected to edit. That is a good thing: it means you can change payouts, locations and job names without touching the logic. It also means the config is the part of the resource most likely to break. A missing comma, a job name with a capital letter, a vector3 with only two numbers, or a minimum payout bigger than the maximum, and the script either throws an error deep inside a thread or, worse, keeps running with nonsense values.
This post shows a small pattern for checking the config when the resource starts, printing clear messages, and refusing to run the job logic until the config is fixed. It needs no extra libraries, so you can drop it into any resource.
An example config
Here is a typical config for a simple delivery job:
-- shared/config.lua
Config = {}
Config.Job = 'delivery' -- must match a job name in your framework
Config.RequiredItem = 'package' -- item the player must carry
Config.Payout = { min = 150, max = 300 }
Config.Cooldown = 600 -- seconds between runs
Config.Depots = {
{ label = 'Docks', coords = vector3(1205.4, -3110.2, 5.5), heading = 90.0 },
{ label = 'Airport', coords = vector3(-1037.8, -2737.6, 20.2), heading = 330.0 },
}
Everything here can go wrong after a server owner edits it. The goal is to catch those mistakes on the first start rather than when the first player clocks in.
The idea
The pattern has three parts:
- A few tiny helper functions that record errors instead of throwing them.
- A
validateConfigfunction that checks each field and collects every problem it finds. - A flag,
ConfigOk, that the rest of the server code checks before doing anything.
Collecting all errors at once matters: if the validator stops at the first problem, the owner fixes one line, restarts, and hits the next one.
The helpers
-- server/validate.lua
local errors = {}
local function fail(path, msg)
errors[#errors + 1] = ('%s: %s'):format(path, msg)
end
local function expectType(path, value, expected)
local actual = type(value)
if actual ~= expected then
fail(path, ('expected %s, got %s'):format(expected, actual))
return false
end
return true
end
local function expectNumberRange(path, value, min, max)
if not expectType(path, value, 'number') then return false end
if (min and value < min) or (max and value > max) then
fail(path, ('%s is outside the allowed range %s to %s')
:format(value, tostring(min), tostring(max)))
return false
end
return true
end
One detail worth knowing: in FiveM's Lua runtime, vectors are their own type. type(vector3(1.0, 2.0, 3.0)) returns 'vector3', not 'table' or 'userdata'. That makes it easy to check that coordinates really are vectors, which catches the common mistake of writing a plain table like {1205.4, -3110.2, 5.5} or using vector2 by accident.
The validator
local function validateConfig()
if not expectType('Config', Config, 'table') then return end
if expectType('Config.Job', Config.Job, 'string') and Config.Job == '' then
fail('Config.Job', 'must not be empty')
end
expectType('Config.RequiredItem', Config.RequiredItem, 'string')
if expectType('Config.Payout', Config.Payout, 'table') then
local okMin = expectNumberRange('Config.Payout.min', Config.Payout.min, 0)
local okMax = expectNumberRange('Config.Payout.max', Config.Payout.max, 0)
if okMin and okMax and Config.Payout.min > Config.Payout.max then
fail('Config.Payout', 'min is larger than max')
end
end
expectNumberRange('Config.Cooldown', Config.Cooldown, 0, 86400)
if expectType('Config.Depots', Config.Depots, 'table') then
if #Config.Depots == 0 then
fail('Config.Depots', 'needs at least one depot')
end
local seen = {}
for i, depot in ipairs(Config.Depots) do
local p = ('Config.Depots[%d]'):format(i)
if expectType(p, depot, 'table') then
if expectType(p .. '.label', depot.label, 'string') then
if seen[depot.label] then
fail(p .. '.label', ('duplicate label "%s"'):format(depot.label))
end
seen[depot.label] = true
end
expectType(p .. '.coords', depot.coords, 'vector3')
expectNumberRange(p .. '.heading', depot.heading, 0, 360)
end
end
end
end
Each check uses a readable path such as Config.Depots[2].coords, so the message points the owner straight at the line to fix.
Running it on start
ConfigOk = false
CreateThread(function()
validateConfig()
local resource = GetCurrentResourceName()
if #errors > 0 then
print(('^1[%s] %d config problem(s) found:^7'):format(resource, #errors))
for _, e in ipairs(errors) do
print(('^1[%s]^7 %s'):format(resource, e))
end
print(('^1[%s] job is disabled until config.lua is fixed.^7'):format(resource))
return
end
ConfigOk = true
print(('^2[%s] config OK^7'):format(resource))
end)
The ^1, ^2 and ^7 codes colour the console output red, green and back to white, which makes problems hard to miss in a busy server log.
Then guard your event handlers and callbacks:
RegisterNetEvent('delivery:startRun', function(depotIndex)
if not ConfigOk then return end
local src = source
local depot = Config.Depots[tonumber(depotIndex) or 0]
if not depot then return end
-- normal job logic continues here
end)
The resource stays started, so nothing else on the server breaks, but the job simply refuses to run until the config is valid. That is far better than paying out nil or spawning a vehicle at the bottom of the ocean.
Checking against the framework
Type checks catch syntax-level mistakes. The most common real-world problem, though, is a job or item name that does not exist on this particular server. You can add an optional second pass that asks the framework.
For ESX Legacy, the server object exposes the loaded jobs and item labels:
local function validateAgainstESX(ESX)
local jobs = ESX.GetJobs()
if jobs and not jobs[Config.Job] then
fail('Config.Job', ('job "%s" is not registered in ESX'):format(Config.Job))
end
if not ESX.GetItemLabel(Config.RequiredItem) then
fail('Config.RequiredItem', ('item "%s" does not exist'):format(Config.RequiredItem))
end
end
For QBCore, the shared tables serve the same purpose: QBCore.Shared.Jobs[Config.Job] and QBCore.Shared.Items[Config.RequiredItem]. If your server uses a separate inventory resource, check items through that inventory's own API instead, because the framework's item list may be empty.
Two cautions. First, run this pass a moment after start, once the framework has finished loading its jobs and items; checking too early can report false errors. Second, exact function names can differ between framework versions, so confirm them against the version you run. Wrapping the framework pass in pcall keeps a version mismatch from crashing your validator.
Small habits that make configs safer
- Comment every field with its unit and an example, as in the config above. "Seconds" versus "minutes" causes more bugs than bad code.
-
Keep secrets out of shared files. Anything in a
shared_scriptis sent to every client, so webhook URLs and API keys belong in a server-only file. - Ship defaults. Fill missing optional values with sensible defaults instead of failing, and reserve hard errors for values the job cannot run without.
The open source advantage
When you can read and edit a resource's code, you can add a validator like this yourself in a few minutes, even if the original author did not. That is one of the practical benefits of open source releases such as the ESX job scripts on xFiveM Shop: you are not stuck guessing why a locked file fails silently. If you are setting up a new resource and want the general install steps first, see xfivem.shop/help/installation.
Summary
A config validator is around a hundred lines of plain Lua. It turns vague runtime errors into a clear list of problems on the first start and protects your economy from broken values. Add it once, then reuse it across resources.
Top comments (0)