Tutorial 2026-05-17

FiveM License & Permit System Development

OntelMonke

OntelMonke

Developer at Agency Scripts

Licenses as Roleplay Infrastructure

A license and permit system is foundational infrastructure for any serious roleplay server. Without it, every player can do everything from day one. Licenses gate activities behind realistic requirements, creating progression, consequences, and roleplay interactions. A driving license means players must pass a test before legally operating vehicles. A weapons permit requires background checks and training. A hunting license restricts who can hunt and where. A business license controls who can operate commercial enterprises. A fishing permit, a pilot license, a medical license, a law license -- each creates a layer of regulation that mirrors real-world systems and generates organic interactions between citizens, government workers, and law enforcement. When a police officer pulls someone over and checks their license status, that is a roleplay moment that only exists because the license system makes it possible. This tutorial walks through building a flexible, extensible license framework that supports any type of permit your server needs.

Flexible License Type Configuration

Rather than hardcoding specific license types, build a configuration-driven system where adding a new license type requires only a new entry in a config table. Each license type definition includes a unique identifier, display name, description, cost to apply, whether it requires an exam, the exam questions if applicable, validity duration before renewal is needed, which jobs or roles can issue it, and what gameplay restrictions apply when the license is missing. This approach means your hunting license, driving license, weapons permit, and business license all run on the same underlying engine. When your server grows and needs a new permit type like a taxi medallion or a construction permit, you add a config entry and the entire application, issuance, and enforcement pipeline works automatically without writing new code.

-- shared/config.lua
Config = {}

Config.LicenseTypes = {
    driving = {
        label       = 'Driving License',
        description = 'Required to legally operate motor vehicles',
        cost        = 500,
        requiresExam = true,
        examType    = 'practical',
        validDays   = 365,
        issuedBy    = {'dmv', 'police'},
        restrictions = {'vehicle_operation'},
    },
    weapons = {
        label       = 'Weapons Permit',
        description = 'Required to legally carry firearms',
        cost        = 2500,
        requiresExam = true,
        examType    = 'written',
        validDays   = 180,
        issuedBy    = {'police'},
        restrictions = {'weapon_carry'},
        prerequisites = {'driving'},
    },
    hunting = {
        label       = 'Hunting License',
        description = 'Required for legal hunting activities',
        cost        = 750,
        requiresExam = true,
        examType    = 'written',
        validDays   = 90,
        issuedBy    = {'ranger', 'dmv'},
        restrictions = {'hunting_activity'},
    },
    fishing = {
        label       = 'Fishing Permit',
        description = 'Required for legal fishing activities',
        cost        = 200,
        requiresExam = false,
        validDays   = 30,
        issuedBy    = {'dmv', 'ranger'},
        restrictions = {'fishing_activity'},
    },
    business = {
        label       = 'Business License',
        description = 'Required to operate a commercial business',
        cost        = 5000,
        requiresExam = false,
        validDays   = 365,
        issuedBy    = {'government'},
        restrictions = {'business_operation'},
    },
    pilot = {
        label       = 'Pilot License',
        description = 'Required to operate aircraft',
        cost        = 10000,
        requiresExam = true,
        examType    = 'practical',
        validDays   = 180,
        issuedBy    = {'faa'},
        restrictions = {'aircraft_operation'},
        prerequisites = {'driving'},
    },
}

Application and Examination Flow

The application process should feel like a real government interaction. Players visit a DMV location, post office, or relevant government building and interact with an NPC or kiosk to begin the application. The NUI presents the available license types, their costs, and requirements. After selecting a license and paying the fee, the system checks prerequisites. If the license requires a written exam, the player enters a quiz interface with multiple-choice questions pulled from a configurable question pool. Randomize question order and answer positions to prevent memorization cheating. Require a minimum passing score, typically 70 to 80 percent. For practical exams like driving tests, spawn a vehicle and create a checkpoint route the player must complete within a time limit while following traffic laws. A driving instructor NPC or a real player working the DMV job can proctor the test and fail students who run red lights, speed excessively, or crash. Store the exam result and, on pass, issue the license with its validity period starting from the current timestamp.

-- server/licenses.lua
function ApplyForLicense(playerId, licenseType)
    local config = Config.LicenseTypes[licenseType]
    if not config then return false, 'Invalid license type' end

    local identifier = GetPlayerIdentifier(playerId, 0)

    -- Check prerequisites
    if config.prerequisites then
        for _, prereq in ipairs(config.prerequisites) do
            local has = HasValidLicense(identifier, prereq)
            if not has then
                return false, 'Missing prerequisite: ' ..
                    Config.LicenseTypes[prereq].label
            end
        end
    end

    -- Check if already has active license
    local existing = MySQL.single.await([[
        SELECT id, status, expires_at FROM licenses
        WHERE identifier = ? AND license_type = ?
        AND status IN ('active','suspended')
    ]], {identifier, licenseType})

    if existing and existing.status == 'active' then
        return false, 'You already hold this license'
    end

    -- Deduct application fee
    local paid = exports['framework']:RemoveMoney(
        playerId, config.cost, 'bank')
    if not paid then
        return false, 'Insufficient funds ($' .. config.cost .. ' required)'
    end

    -- Create pending application
    local appId = MySQL.insert.await([[
        INSERT INTO license_applications
            (identifier, license_type, status, applied_at)
        VALUES (?, ?, 'pending', NOW())
    ]], {identifier, licenseType})

    if config.requiresExam then
        return true, 'Application submitted. Please complete the exam.', appId
    else
        -- No exam required, issue directly
        IssueLicense(identifier, licenseType)
        return true, 'License issued successfully!'
    end
end

function IssueLicense(identifier, licenseType)
    local config = Config.LicenseTypes[licenseType]
    local expiresAt = os.time() + (config.validDays * 86400)

    MySQL.insert([[
        INSERT INTO licenses
            (identifier, license_type, status, issued_at, expires_at)
        VALUES (?, ?, 'active', NOW(), FROM_UNIXTIME(?))
        ON DUPLICATE KEY UPDATE
            status = 'active', issued_at = NOW(),
            expires_at = FROM_UNIXTIME(?)
    ]], {identifier, licenseType, expiresAt, expiresAt})
end

function HasValidLicense(identifier, licenseType)
    local result = MySQL.single.await([[
        SELECT id FROM licenses
        WHERE identifier = ? AND license_type = ?
        AND status = 'active' AND expires_at > NOW()
    ]], {identifier, licenseType})
    return result ~= nil
end

exports('HasValidLicense', HasValidLicense)

Law Enforcement Integration

The real power of a license system emerges when law enforcement can query and modify license statuses during roleplay interactions. Police officers need the ability to check a citizen's license status during traffic stops, verify weapons permits during searches, and suspend or revoke licenses as consequences for criminal behavior. Build a police MDT integration that shows all licenses held by a citizen along with their status, issue date, and expiration date. Add commands or MDT actions for suspending a license with a reason and duration, revoking it permanently, and reinstating it after a suspension period. When a license is suspended, the holder should receive a notification explaining the suspension reason and duration. Integrate license checks into existing enforcement workflows. If a player is caught driving without a valid license, the police system can automatically flag it during a traffic stop. If someone is caught hunting without a permit, rangers can issue citations that carry fines and potential license revocation.

-- server/enforcement.lua
RegisterNetEvent('licenses:checkCitizen', function(targetId)
    local src = source
    -- Verify requesting player is law enforcement
    local job = exports['framework']:GetPlayerJob(src)
    if job ~= 'police' and job ~= 'ranger' and job ~= 'sheriff' then
        return
    end

    local targetIdentifier = GetPlayerIdentifier(targetId, 0)
    local licenses = MySQL.query.await([[
        SELECT license_type, status, issued_at, expires_at,
               suspended_reason, suspended_until
        FROM licenses WHERE identifier = ?
    ]], {targetIdentifier})

    TriggerClientEvent('licenses:showResults', src, licenses)
end)

RegisterNetEvent('licenses:suspend', function(targetIdentifier, licType, reason, days)
    local src = source
    local job = exports['framework']:GetPlayerJob(src)
    if job ~= 'police' and job ~= 'judge' then return end

    local suspendUntil = os.time() + (days * 86400)

    MySQL.update([[
        UPDATE licenses SET
            status = 'suspended',
            suspended_reason = ?,
            suspended_until = FROM_UNIXTIME(?)
        WHERE identifier = ? AND license_type = ?
    ]], {reason, suspendUntil, targetIdentifier, licType})

    -- Notify the affected player if online
    local targetPlayer = GetPlayerFromIdentifier(targetIdentifier)
    if targetPlayer then
        TriggerClientEvent('licenses:notify', targetPlayer,
            'Your ' .. Config.LicenseTypes[licType].label ..
            ' has been suspended for ' .. days .. ' days. Reason: ' .. reason)
    end

    -- Log the action
    MySQL.insert([[
        INSERT INTO license_logs
            (identifier, license_type, action, performed_by, reason)
        VALUES (?, ?, 'suspend', ?, ?)
    ]], {targetIdentifier, licType, GetPlayerIdentifier(src, 0), reason})
end)

Renewal System and Expiration Handling

Licenses with expiration dates create recurring revenue and regular NPC interactions that keep the world feeling alive. When a license approaches its expiration date, send the holder a notification either through the phone system or the mail system covered in a separate tutorial. Give a grace period of a few in-game days where the license is technically expired but the player is not immediately penalized, allowing time to renew. Renewal should be simpler than initial application, requiring only the fee and no re-examination unless the license was previously suspended or revoked. Implement a server-side scheduled task that runs periodically to check for expired licenses and update their status. For licenses that require periodic renewal like fishing permits with short validity periods, consider offering bulk renewal options where players can pay for multiple periods upfront at a slight discount. This rewards dedicated players who plan ahead while maintaining the renewal cycle that keeps the system feeling dynamic.

Restriction Enforcement Across Scripts

The license system only matters if other scripts actually enforce the restrictions. Create a centralized export function that any script can call to check whether a player holds a valid license of a given type. Your vehicle script checks for a driving license before allowing engine start or when a player enters the driver seat. Your weapons script checks for a weapons permit when a player equips a firearm. Your hunting script checks for a hunting license before allowing animal harvesting. Your fishing script checks for a fishing permit before allowing catches. Each script decides its own consequence for violations. Some might simply prevent the action with a notification. Others might allow the action but flag the player for law enforcement attention, triggering an automatic wanted level or alert. The strictness is up to your server's roleplay philosophy. The license system provides the data layer; enforcement scripts decide the consequences. This separation means you can adjust enforcement strictness without touching the license system itself.

Database Schema and Admin Tools

The database schema needs tables for license definitions are already in config, active licenses, applications, exam results, and an audit log. The audit log is critical for accountability. Every license issuance, suspension, revocation, and reinstatement gets recorded with a timestamp, the performing officer's identifier, and the reason. Admin commands should allow staff to issue any license directly for event purposes, clear all suspensions during amnesty events, bulk-expire licenses of a specific type for system resets, and view a complete license history for any player. Build an admin panel in your server's web dashboard or MDT that provides at-a-glance statistics on active licenses by type, pending applications, recent suspensions, and upcoming expirations. This data helps you understand how players interact with the system and whether fees and validity periods need adjustment to maintain healthy engagement.

-- SQL schema
CREATE TABLE IF NOT EXISTS licenses (
    id                INT AUTO_INCREMENT PRIMARY KEY,
    identifier        VARCHAR(64) NOT NULL,
    license_type      VARCHAR(32) NOT NULL,
    status            ENUM('active','suspended','revoked','expired')
                      DEFAULT 'active',
    issued_at         TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at        TIMESTAMP NULL,
    suspended_reason  VARCHAR(255) DEFAULT NULL,
    suspended_until   TIMESTAMP NULL,
    UNIQUE KEY unique_license (identifier, license_type),
    INDEX idx_status (status),
    INDEX idx_expires (expires_at)
);

CREATE TABLE IF NOT EXISTS license_applications (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    identifier    VARCHAR(64) NOT NULL,
    license_type  VARCHAR(32) NOT NULL,
    status        ENUM('pending','passed','failed','cancelled')
                  DEFAULT 'pending',
    exam_score    INT DEFAULT NULL,
    applied_at    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    completed_at  TIMESTAMP NULL
);

CREATE TABLE IF NOT EXISTS license_logs (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    identifier    VARCHAR(64) NOT NULL,
    license_type  VARCHAR(32) NOT NULL,
    action        VARCHAR(32) NOT NULL,
    performed_by  VARCHAR(64),
    reason        VARCHAR(255),
    created_at    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_identifier (identifier)
);

Share this article

Ready to upgrade your server?

Check out our premium FiveM scripts in the Agency Scripts store or join our Discord community for support and updates.