Browse docs

Garage Integration

Persist Sky tuning through QBCore, ESX, Qbox, qb-garages, and custom garage property pipelines.

What this guide solves

A garage saves vehicle tuning in four steps:

  1. The garage reads the properties of the live vehicle.
  2. The server stores the properties as JSON.
  3. The garage reads that JSON when the vehicle is spawned.
  4. The client applies the properties to the spawned vehicle.

Most QBCore and ESX garages already call the central framework functions for steps 1 and 4. The cleanest integration is therefore to synchronize those two functions with Sky. Qbox uses its supported Ox property and qbx_vehicles pipeline instead; do not patch Qbox core files.

qb-garages still needs an additional fix: some versions try to save properties through a qb-mechanicjob event that does not exist when Sky Mechanic Job is installed. The complete fix for that is included below.

Two persistence records

Sky Mechanic Job and the garage use separate storage locations:

StorageWhat it contains
Garage vehicle columnThe framework's owned-vehicle snapshot for paint, wheels, performance mods, extras, neon, windows, damage, and other GTA properties
sky_mechanic_vehicle_tuning.propertiesA compatibility snapshot of GTA properties plus Mechanic-specific _skyMechanicTuning and _skyMechanicPaint extensions such as Stancer, custom handling, Nitro, anti-lag, two-step, and Engine Sounds

The normal garage columns are usually:

  • QBCore: player_vehicles.mods
  • ESX: owned_vehicles.vehicle
  • Qbox: player_vehicles.mods, managed through qbx_vehicles

The two snapshots overlap in the current implementation. Keep both: the garage record owns the normal owned-vehicle spawn, while Mechanic Job reapplies its persisted tuning after the vehicle is available. If their paint, mods, neon, or xenon values differ, the restore that runs last can overwrite the other. Compare both JSON payloads and the apply order when only some tuning changes after a garage cycle.

Do not replace sky_mechanic_vehicle_tuning.properties with the garage column. Both storage locations are required and have different purposes.

Before you start

  1. For QBCore or ESX, back up the framework client file.
  2. Back up the garage resource.
  3. Back up the owned-vehicle database table.
  4. Check whether your server uses QBCore, ESX, or Qbox.
  5. Complete only the matching framework section below.

QBCore and ESX source of truth

The complete Sky implementation is stored in:

sky_base/config/vehicle_functions.lua

The two source functions are:

Functions.Vehicle.GetVehicleProperties(vehicle)
Functions.Vehicle.SetVehicleProperties(vehicle, props)

Whenever Sky adds or changes a property, synchronize the contents of these two functions with the matching framework functions.

Most of the property code uses GTA natives and can be shared unchanged. Fuel is the intentional exception: inside sky_base, the getter and setter call Sky.Functions.GetVehicleFuel and Sky.Functions.SetVehicleFuel so the configured Sky fuel provider is used. Those two calls are valid inside sky_base, but they must not be copied into qb-core or es_extended. FiveM Lua globals are resource-local, and those framework resources do not load the Sky bootstrap. A manifest dependency can control start order, but it does not share the Sky global between resources.

Required fuel substitutions

After copying the Sky getter body into a framework function, replace:

fuelLevel = round(Sky.Functions.GetVehicleFuel(vehicle), 1),

with the framework-local native version:

fuelLevel = round(GetVehicleFuelLevel(vehicle), 1),

After copying the Sky setter body, replace:

Sky.Functions.SetVehicleFuel(vehicle, props.fuelLevel)

with:

SetVehicleFuelLevel(vehicle, props.fuelLevel + 0.0)

If the framework functions already use a fuel-resource export, preserve those original two fuel calls instead of the native examples. The target function must use only APIs available inside its own framework resource.

Never leave a Sky.* call in qb-core/client/functions.lua or es_extended/client/functions.lua. An F8 error such as attempt to index a nil value (global 'Sky') means the fuel substitution was skipped. The getter then returns no property table, while the setter stops before paint, xenon, and the remaining mods are applied.
Do not copy only selected mod fields. The complete function contents must stay synchronized so paint, damage, wheels, tyres, windows, extras, neon, xenon, liveries, and framework-compatible aliases use the same schema.

QBCore: connect the central functions to Sky

Step 1: Open the correct file

Open:

qb-core/client/functions.lua

Search for:

function QBCore.Functions.GetVehicleProperties(vehicle)

The original function is long and reads every vehicle property with GTA natives.

Step 2: Synchronize the getter

Open these files next to each other:

Source: sky_base/config/vehicle_functions.lua
Target: qb-core/client/functions.lua

In the Sky file, find:

function Functions.Vehicle.GetVehicleProperties(vehicle)

Copy everything inside that function, but do not copy its first function ... line or its final closing end. Then apply the required fuel substitution before restarting the resource.

In QBCore, delete the old contents of:

function QBCore.Functions.GetVehicleProperties(vehicle)

Paste the copied Sky function contents directly into it. The resulting structure is:

qb-core/client/functions.lua
function QBCore.Functions.GetVehicleProperties(vehicle)
    -- Paste the complete contents of
    -- Functions.Vehicle.GetVehicleProperties(vehicle) here.
end

Both functions now use the same parameter name and the same raw vehicle entity.

Step 3: Synchronize the setter

In the same file, search for:

function QBCore.Functions.SetVehicleProperties(vehicle, props)

In sky_base/config/vehicle_functions.lua, find:

function Functions.Vehicle.SetVehicleProperties(vehicle, props)

Copy everything inside that function, excluding its first function ... line and its final closing end. Then apply the required fuel substitution.

Delete the old contents of the QBCore setter and paste the Sky contents directly into it:

qb-core/client/functions.lua
function QBCore.Functions.SetVehicleProperties(vehicle, props)
    -- Paste the complete contents of
    -- Functions.Vehicle.SetVehicleProperties(vehicle, props) here.
end

Every compatible resource that calls these QBCore functions now uses the same property schema, while QBCore keeps its own fuel integration:

QBCore.Functions.GetVehicleProperties(vehicle)
QBCore.Functions.SetVehicleProperties(vehicle, props)

Step 4: Do not change the QBCore manifest

Do not add this:

dependency "sky_base"

to qb-core/fxmanifest.lua. Sky Base already needs QBCore, so adding the reverse dependency would create a circular manifest dependency.

ESX: connect the central functions to Sky

Step 1: Open the correct file

Open:

es_extended/client/functions.lua

Search for:

function ESX.Game.GetVehicleProperties(vehicle)

Step 2: Synchronize the getter

Copy the complete inner contents of:

function Functions.Vehicle.GetVehicleProperties(vehicle)

from sky_base/config/vehicle_functions.lua.

Apply the required fuel substitution immediately after pasting the getter body.

Delete the old contents of the ESX getter and paste the copied contents directly:

es_extended/client/functions.lua
function ESX.Game.GetVehicleProperties(vehicle)
    -- Paste the complete Sky getter contents here.
end

Step 3: Synchronize the setter

Search for:

function ESX.Game.SetVehicleProperties(vehicle, props)

Copy the complete inner contents of:

function Functions.Vehicle.SetVehicleProperties(vehicle, props)

Delete the old ESX setter contents, apply the required fuel substitution, then use:

es_extended/client/functions.lua
function ESX.Game.SetVehicleProperties(vehicle, props)
    -- Paste the complete Sky setter contents here.
end

Step 4: Do not change the ESX manifest

Do not make es_extended depend on sky_base. Keep the normal start direction:

es_extended → sky_base → garage

Normal ESX garages that already use ESX.Game.GetVehicleProperties and ESX.Game.SetVehicleProperties now use the synchronized property schema automatically while ESX keeps its own fuel integration.

Qbox: keep the supported property pipeline

Do not paste either Sky vehicle-property function into qbx_core, its QBCore bridge, qbx_garages, or ox_lib. Qbox explicitly recommends leaving core code unchanged. Current qbx_garages captures the vehicle with lib.getVehicleProperties(vehicle), stores that property table through qbx_vehicles:SaveVehicle, and passes the stored properties into the owned-vehicle spawn flow.

Keep these Qbox/Ox APIs in their existing roles. Capture the properties on the client:

custom-qbox-garage/client.lua
local properties = lib.getVehicleProperties(vehicle)

Pass that table to the server through the garage's existing callback. Store it on the server:

custom-qbox-garage/server.lua
exports.qbx_vehicles:SaveVehicle(vehicle, {
    props = properties
})

When a custom server-side spawn flow creates the owned vehicle, pass the stored properties during spawn:

custom-qbox-garage/server.lua
qbx.spawnVehicle({
    -- Keep the garage's model, spawn source, and other options.
    props = properties
})

The standard current qbx_garages already follows this flow and needs no Sky code copied into it. A custom Qbox garage should integrate with these supported APIs instead of editing qbx_core. No Sky.* call belongs in a Qbox core resource.

QBCore: additional qb-garages fix

Changing the central QBCore getter and setter is not enough for affected qb-garages versions.

Some versions contain this call while parking:

TriggerServerEvent(
    "qb-mechanicjob:server:SaveVehicleProps",
    QBCore.Functions.GetVehicleProperties(vehicle)
)

That event belongs to a different Mechanic Job resource. Sky Mechanic Job does not provide it, so the event goes nowhere and player_vehicles.mods remains outdated.

The following steps make qb-garages save the JSON itself.

Step 1: Configure the property column

Open qb-garages/config.lua and add:

qb-garages/config.lua
Config.VehiclePropertiesStorage = {
    table = "player_vehicles",
    column = "mods",
    plateColumn = "plate",
    ownerColumn = "citizenid",
    ownerField = "citizenid"
}

Only change these values if your owned-vehicle table uses different names:

  • table: owned-vehicle table
  • column: JSON property column
  • plateColumn: plate column
  • ownerColumn: database owner column
  • ownerField: matching value in Player.PlayerData

Step 2: Read the properties before parking

Open qb-garages/client.lua and find:

local function DepositVehicle(veh, data)

Directly after the plate is read, add:

local plate = QBCore.Functions.GetPlate(veh)
local vehicle_properties = QBCore.Functions.GetVehicleProperties(veh)

Because the central QBCore getter was synchronized above, this call now produces the same property table as Sky.

Remove the complete old event:

TriggerServerEvent(
    "qb-mechanicjob:server:SaveVehicleProps",
    QBCore.Functions.GetVehicleProperties(veh)
)

Pass vehicle_properties as the final argument of the existing canDeposit callback:

QBCore.Functions.TriggerCallback(
    "qb-garages:server:canDeposit",
    function(can_deposit)
        -- Keep the existing garage code here.
    end,
    plate,
    data.type,
    data.indexgarage,
    1,
    vehicle_properties
)

Keep the existing fuel, damage, vehicle deletion, outside-vehicle, and notification code.

Step 3: Add the server-side storage helpers

Open qb-garages/server.lua. Add this block above the callback registrations:

qb-garages/server.lua
local function getVehiclePropertiesStorage(Player)
    local storage = Config.VehiclePropertiesStorage
    local identifiers = {
        storage.table,
        storage.column,
        storage.plateColumn,
        storage.ownerColumn,
        storage.ownerField
    }

    for _, identifier in ipairs(identifiers) do
        if type(identifier) ~= "string" or not identifier:match("^[%w_]+$") then
            error(("Invalid Config.VehiclePropertiesStorage identifier: %s"):format(tostring(identifier)))
        end
    end

    local owner = Player.PlayerData[storage.ownerField]
    if not owner then
        error(("PlayerData.%s is unavailable for vehicle property persistence"):format(storage.ownerField))
    end

    return storage, owner
end

local function saveVehicleProperties(Player, plate, properties)
    if type(properties) ~= "table" then return false end

    local storage, owner = getVehiclePropertiesStorage(Player)
    properties.plate = plate

    local query = ("UPDATE `%s` SET `%s` = ? WHERE `%s` = ? AND `%s` = ?"):format(
        storage.table,
        storage.column,
        storage.plateColumn,
        storage.ownerColumn
    )
    local affected_rows = MySQL.update.await(query, {
        json.encode(properties),
        plate,
        owner
    })

    if affected_rows > 0 then return true end

    -- MySQL can report zero affected rows when the stored JSON is already identical.
    local exists_query = ("SELECT 1 FROM `%s` WHERE `%s` = ? AND `%s` = ? LIMIT 1"):format(
        storage.table,
        storage.plateColumn,
        storage.ownerColumn
    )
    return MySQL.scalar.await(exists_query, { plate, owner }) ~= nil
end

local function loadVehicleProperties(Player, plate)
    local storage, owner = getVehiclePropertiesStorage(Player)
    local query = ("SELECT `%s` AS properties FROM `%s` WHERE `%s` = ? AND `%s` = ? LIMIT 1"):format(
        storage.column,
        storage.table,
        storage.plateColumn,
        storage.ownerColumn
    )
    local row = MySQL.single.await(query, { plate, owner })

    if not row or not row.properties then return {} end

    return json.decode(row.properties) or {}
end

The update checks both the plate and the owner. A player therefore cannot overwrite another player's vehicle by submitting its plate.

Step 4: Save inside canDeposit

Find:

QBCore.Functions.CreateCallback(
    "qb-garages:server:canDeposit",
    function(source, cb, plate, vehicle_type, garage, state)

Add properties as the final parameter:

QBCore.Functions.CreateCallback(
    "qb-garages:server:canDeposit",
    function(source, cb, plate, vehicle_type, garage, state, properties)

After ownership has been confirmed, but before the vehicle is marked as parked, add:

if not saveVehicleProperties(Player, plate, properties) then
    cb(false)
    return
end

The order must be:

  1. Confirm ownership.
  2. Save the property JSON.
  3. Mark the vehicle as parked.
  4. Return success to the client.
  5. Delete the live vehicle on the client.

Step 5: Load the configured property column

Find the server callback:

"qb-garages:server:spawnvehicle"

Get the QBCore player at the beginning:

local Player = exports["qb-core"]:GetPlayer(source)
if not Player then return end

Load the configured property column:

local vehicle_properties = loadVehicleProperties(Player, plate)

Return it with the spawned network ID and plate:

cb(net_id, vehicle_properties, plate)

Step 6: Keep the normal QBCore setter

When the client receives the spawned vehicle, it should use:

QBCore.Functions.SetVehicleProperties(vehicle, props)

Do not replace this call in every garage. The central QBCore setter already contains the same implementation as Sky.

Keep the garage's existing fuel, keys, engine state, visual damage, warp, and outside-vehicle code.

ESX garages

After changing the two central ESX functions, most ESX garages need no client modification.

Confirm that the garage:

  1. Uses ESX.Game.GetVehicleProperties while parking.
  2. Encodes the complete returned table with json.encode.
  3. Stores it in owned_vehicles.vehicle.
  4. Reads and decodes the same column while spawning.
  5. Uses ESX.Game.SetVehicleProperties after the vehicle exists.

If all five points are already true, no garage code needs to be changed.

Custom garages that bypass the framework

Some garages use their own adapter functions instead of the central framework functions. Common names are:

CL.GetVehicleProperties
CL.SetVehicleProperties

When possible, make the adapter call the synchronized framework functions:

CL.GetVehicleProperties = function(vehicle)
    return QBCore.Functions.GetVehicleProperties(vehicle)
end

CL.SetVehicleProperties = function(vehicle, props)
    QBCore.Functions.SetVehicleProperties(vehicle, props)
end

For an ESX garage, use ESX.Game.GetVehicleProperties and ESX.Game.SetVehicleProperties instead. For Qbox, keep the Ox/qbx_vehicles flow described above. If the adapter supports multiple frameworks, select the matching path in its existing framework branch.

Updates and maintenance

Framework and Sky updates can make the copies different again. After updating QBCore, ESX, or Sky:

  1. Open sky_base/config/vehicle_functions.lua.
  2. Open the framework client functions file next to it.
  3. Compare both getter bodies.
  4. Compare both setter bodies.
  5. Copy the Sky contents again when anything differs.
  6. Reapply the two framework-local fuel substitutions.
  7. Search the framework target file for Sky. and confirm that no matches remain.

No self adapter is needed. Sky, QBCore, and ESX all pass the raw vehicle entity as vehicle.

The actual property implementation remains configurable in:

sky_base/config/vehicle_functions.lua

Step-by-step test

Use one owned test vehicle:

  1. Start the framework, sky_base, the garage, and sky_mechanicjob.
  2. Take the vehicle out.
  3. Change an obvious value such as paint, wheels, or an engine upgrade.
  4. Park the vehicle.
  5. Check the database:
    • QBCore: player_vehicles.mods
    • ESX: owned_vehicles.vehicle
    • Qbox: the player_vehicles property record managed by qbx_vehicles
  6. Confirm that the JSON contains the plate and changed mod values.
  7. Take the vehicle out again.
  8. Confirm that the tuning was restored.
  9. Restart the server.
  10. Retrieve the vehicle and verify it again.

Then test:

  • custom and standard paint
  • wheels and custom tyres
  • performance upgrades
  • extras
  • neon and xenon
  • windows, doors, and tyre state
  • livery
  • Stancer
  • Nitro, anti-lag, and two-step
  • Engine Sound

If tuning is still missing

Check these points in order:

  1. Does F8 show attempt to index a nil value (global 'Sky') from a framework property function?
  2. Is there any Sky. call left in the framework getter or setter?
  3. On QBCore or ESX, does the framework getter still match the Sky getter contents apart from the local fuel call? On Qbox, does the garage still call lib.getVehicleProperties?
  4. Does the garage JSON column change while parking?
  5. Is the database update restricted by plate and owner?
  6. Does the spawn callback read the same JSON column?
  7. On QBCore or ESX, does the framework setter still match the Sky setter contents apart from the local fuel call? On Qbox, are the stored properties passed into the owned-vehicle spawn flow?
  8. Does sky_base start after the framework and before the garage?
  9. Does sky_mechanic_vehicle_tuning contain a row for the exact plate?
  10. Does another script apply old properties after the framework setter?

If normal paint and wheels are missing, inspect the framework and garage property flow. If only Stancer, Nitro, anti-lag, or Engine Sound is missing, inspect sky_mechanic_vehicle_tuning.properties and the exact plate value.