Sandbox mode
RAW specs need rework !!
GLPI Sandbox Mode — Specification
Overview
Sandbox mode lets administrators spin up an isolated GLPI environment that shares the same application codebase but operates against a separate database. This single feature covers three distinct use cases:
Use case | Primary need |
|---|---|
Break-testing | Admin wants to try risky config changes without touching production data |
Demo / discovery | Cloud instance pre-loaded with seeded data so users see a useful GLPI on first login |
Testing environment | QA or developers want a near-production dataset to test against |
Upgrade dry-run | Admin validates a GLPI version upgrade against a production-data copy before touching production |
Architecture decision: separate database
A dedicated database was selected after comparing several approaches:
Option | Isolation | Complexity | Sync cost |
|---|---|---|---|
Separate DB (proposed) | Full | Low | Moderate |
Table prefix ( | Partial | High (every query touched) | Low |
Schema flag / soft delete | None | Very high | None |
Separate GLPI install | Full | None in code | Manual |
Dedicated sandbox entity | Partial | Low | Near zero |
A dedicated sandbox entity is worth noting: create a top-level entity flagged as the sandbox, scoped to it when sandbox mode is active. Sync cost is near zero since data lives in the same database and can simply be copied between entities. The trade-offs are significant however: it forces the instance into multi-entity mode (a non-trivial change for instances that were not already using it), isolation is only partial (global configuration, rules, and instance-level settings are shared), and any bug or misconfiguration that ignores entity scoping could leak into production data. It is a valid option for instances that already run multi-entity and accept weaker isolation in exchange for simpler setup.
A separate DB follows the existing replica pattern exactly — GLPI already has config_db.php → DB and config_db_replica.php → DBReplica. Sandbox adds a third file config_db_sandbox.php → DBSandbox following the same convention, and DBConnection grows analogous switchToSandbox() / isSandboxActive() helpers.
The global $DB is swapped at session activation time, exactly as switchToReplica() replaces it today. All existing code (search, forms, history, API) operates unchanged against $DB without knowing it is in sandbox mode.
Migration cost
A separate DB introduces a schema synchronization obligation: both databases must remain on the identical schema version at all times. A sync operation between databases with divergent schemas (e.g. a column added to production but not yet to sandbox) would produce errors or silent data corruption.
The enforced migration order is sandbox first, then production:
php bin/console database:update --sandbox # migrate sandbox schema
php bin/console database:update # migrate production schemaThis is the natural workflow for the upgrade dry-run use case and costs nothing extra — the sandbox migration run is the validation step.
Two guardrails enforce this:
At sync time —
sandbox:syncchecks that both schema versions match before copying any data. If the sandbox schema is behind, it refuses the sync and prompts the admin to rundatabase:update --sandboxfirst.At sandbox entry —
Session::enterSandbox()checks schema version parity. If schemas differ, entry is blocked with a clear message indicating that a migration is pending on the sandbox.
database:update (without --sandbox) should also warn if a sandbox is configured but its schema is now behind the freshly migrated production DB, so the divergence does not go unnoticed.
Availability
Sandbox is opt-in for self-hosted instances
Sandbox mode is disabled by default and requires explicit provisioning by the administrator. It is not enabled at install time. Several reasons justify this:
Resource cost — a sandbox requires a second database of comparable size to production. On self-hosted instances this may double storage requirements, which is not acceptable as a default.
Data sensitivity — some organizations have strict policies about copies of production data. An opt-in model ensures administrators make a conscious decision before any data is duplicated.
Relevance — small or single-user instances gain little from a sandbox. The overhead of maintaining one is not justified for every deployment.
Infrastructure dependency — sandbox may require access to a second database server, credentials, and (for mysqldump sync) CLI access. These cannot be assumed to be available.
Sandbox becomes available only after an administrator explicitly configures it under Setup > Sandbox, providing the sandbox DB credentials. At that point config_db_sandbox.php is written and the feature is live.
Cloud instances
For GLPI cloud instances, the sandbox will be provisioned automatically for all new instances, pre-loaded with seeded demo data. This directly serves the demo/discovery use case and removes the setup friction entirely.
TO DISCUSS: Existing cloud instances: sandbox will be opt-in, offered as an upgrade from the platform side, not pushed automatically (to avoid surprising administrators with a new database appearing in their environment).
Session activation model
Sandbox mode is a per-session flag, stored in $_SESSION, mirroring user impersonation.
$_SESSION['glpi_sandbox_active'] = true;Session::isSandboxActive(): bool — checks the flag.
Session::enterSandbox(): bool — sets the flag, swaps $DB to DBSandbox, logs the event.
Session::exitSandbox(): bool — clears the flag, swaps $DB back to DB, logs the event.
On every request bootstrap (after session resume), if isSandboxActive() the application calls DBConnection::switchToSandbox(). This means the flag survives page navigation without any persistent config change.
Permission guard: at launch, sandbox entry is reserved for users with Config:UPDATE (super-admins). Impersonated users cannot enter sandbox mode (sandbox + impersonation together would be confusing and hard to reason about).
UI entry point
The primary entry point is the top-right user menu dropdown, alongside the existing debug mode toggle. This is consistent with the "mode switch" pattern already established in GLPI. The item is only rendered when sandbox is configured (DBConnection::isSandboxConfigured()):

User menu with sandbox entry
{% if is_sandbox_configured %}
<a href="{{ path('/ajax/switchsandbox.php') }}"
class="dropdown-item {% if is_sandbox_active %}bg-purple-lt{% endif %}"
title="{{ __('Change mode') }}">
<i class="ti ti-flask"></i>
{{ is_sandbox_active ? __('Sandbox mode enabled') : __('Sandbox mode disabled') }}
</a>
{% endif %}The item mirrors the debug toggle pattern: same placement, same guard, same toggle behaviour. When sandbox is active the item gets a purple tint (bg-purple-lt) matching the banner colour, giving a second visible cue of the active mode directly in the menu.
Exit is also reachable via the purple banner button, which remains the primary dismissal point since it is always visible on screen.
Visual indicator
A persistent purple banner is shown at the top of every page when sandbox mode is active, analogous to the existing banner-impersonate (red banner for impersonation). It is rendered by a new Twig partial:

Sandbox banner mockup
templates/layout/parts/sandbox_banner.html.twig
{% if is_sandbox_active %}
<div class="banner-sandbox">
{{ __('You are working in the sandbox environment.') }}
<form method="post" action="{{ path('sandbox_exit') }}" data-submit-once>
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token() }}" />
<button type="submit" class="btn btn-outline-light">
{{ __('Exit sandbox') }}
</button>
</form>
</div>
{% endif %}Included in templates/layout/parts/page_header.html.twig immediately after the impersonate banner include (line 45 today).
Html::getPageHeaderTplVars() adds 'is_sandbox_active' => Session::isSandboxActive() alongside the existing is_impersonate_active key.
CSS (css/legacy/includes/_styles.scss):
div.banner-sandbox {
background: #7c3aed; // purple — distinct from impersonate red
color: #fff;
font-weight: bold;
padding: 10px;
text-align: right;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}The color must be visually distinct from both impersonation (red) and debug mode. Purple is immediately recognizable as a distinct mode indicator and has no existing semantic meaning in GLPI's UI.
Configuration
Database config file
GLPI_CONFIG_DIR/config_db_sandbox.php — generated by DBConnection::createSandboxConfig(), following the exact same structure as createReplicaConnectionFile():
class DBSandbox extends DBmysql {
public $dbhost = 'localhost';
public $dbuser = 'glpi_sandbox';
public $dbpassword = '...';
public $dbdefault = 'glpi_sandbox';
public bool $sandbox = true;
}The $sandbox = true property can be used as a guard to prevent DBSandbox from being accidentally used for writes in contexts where only the main DB should be touched (e.g. cron jobs that run outside of a user session should not respect the sandbox flag).
DBConnection additions
// analogous to isDBReplicaActive()
public static function isSandboxConfigured(): bool;
// analogous to switchToReplica()
public static function switchToSandbox(): bool;
// analogous to createReplicaConnectionFile()
public static function createSandboxConfig(string $host, string $user, string $password, string $dbname): bool;
public static function deleteSandboxConfig(): void;Admin UI
A new section under Setup > System (or a dedicated Setup > Sandbox) with:
Current sandbox DB connection info and status (connected / not configured)
Button: Initialize sandbox (provisions the DB and runs the schema)
Button: Sync from production (see below)
Button: Enter sandbox (switches the current session)
Sandbox DB credentials form (host, user, password, dbname) — same form as the existing replica config
Sandbox initialization
When an admin first sets up the sandbox, two paths are available:
A. Empty database (for testing env)
Run the standard GLPI install migrations against DBSandbox. The result is an empty, schema-identical sandbox. The admin can then seed it manually or via the seeding feature (see Demo use case below).
This is just: create the database, run php bin/console database:install targeting the sandbox config.
B. Clone from production (for break-testing)
Copy the production database into the sandbox. See Sync section.
Data sync: production → sandbox
Sync is the most performance-critical operation. Two strategies are proposed; they are not mutually exclusive.
Strategy 1 — Application-level table copy (simple, always works)
GLPI iterates all tables and copies rows from $DB (production) to DBSandbox. This is done via a cron task or a CLI command (php bin/console sandbox:sync).
Advantages: pure PHP, no OS-level requirements, works on shared hosting. Disadvantage: slow for large datasets (millions of rows); blocks if tables are large.
Mitigation: copy in batches; skip tables that are irrelevant to sandbox (logs, job queues, sent notifications). Provide a config list of tables to exclude.
Estimated feasibility: acceptable for instances < 500k total rows; painful above that.
Strategy 2 — mysqldump / binary backup (CLI only, recommended for large instances)
Execute mysqldump on the production DB and pipe the output into the sandbox DB. This is the standard "clone a MySQL database" operation and is I/O-bound rather than PHP-bound, making it significantly faster than Strategy 1 for large datasets.
This strategy is CLI-only. GLPI deliberately avoids spawning OS-level processes from the web/browser execution path. Invoking system binaries from a web request would expose the underlying system in a way GLPI explicitly does not allow. The web UI must never trigger this path directly.
Acceptable contexts:
php bin/console sandbox:sync— the console command runs in CLI where spawning a subprocess is acceptableA
CronTaskthat runs via the CLI cron runner (php bin/console system:run-crons)
The operation performed at CLI level:
Lock sandbox (set a flag that blocks user entry for the duration)
Drop and recreate sandbox DB tables
mysqldump ... | mysql ...Unlock sandbox
The web UI's "Sync now" button therefore does not run the sync inline. It either:
Schedules the next cron run immediately (same mechanism as "force-run a cron task" in the existing GLPI cron UI), or
Displays the CLI command for the admin to run manually if cron is not configured
Advantages: fast; scales to any DB size; reliable. Disadvantage: requires mysqldump binary reachable by the CLI PHP process. Not available on all hosting environments.
bin/console sandbox:sync detects whether mysqldump is available and falls back to Strategy 1 automatically if it is not, with a warning in the output.
Strategy 3 — MySQL native replication (future / optional)
Configure the sandbox DB as a MySQL replica of production (using SHOW BINARY LOG STATUS / CHANGE REPLICATION SOURCE TO). GLPI already has monitoring infrastructure for this in DBConnection::getReplicationStatus() and cronCheckDBreplicate.
This only works for the read-only / break-testing use case, because any write to the sandbox DB will break replication. This means:
When the user enters sandbox mode and makes writes, replication must be paused.
When the user exits sandbox, replication can optionally be re-enabled (losing sandbox changes).
This is complex to manage and probably not worth implementing in v1. It is noted here as a future direction.
Sync scope configuration
The admin can configure which data categories are synced:
Category | Default |
|---|---|
Assets & CMDB | Yes |
Tickets & ITIL objects | Yes |
Users & profiles | Yes |
Config (entities, groups, etc.) | Yes |
Logs ( | No |
Email queue ( | No |
Cron history | No |
API tokens | No |
Excluding logs and queues significantly reduces sync time and sandbox clutter.
Seeded data for demo instances
For cloud / discovery use cases, the sandbox ships with a seed pack — a static SQL dump that makes GLPI look and feel like a live, mature installation on first load.
Approach: static dump + post-import date shift
The seed pack is a plain mysqldump output, compressed as seed.sql.gz and shipped as a versioned release artifact alongside GLPI. Import is the standard mysql < seed.sql — fast, no PHP involved, no dependency on any framework.
The only problem with a static dump is stale dates: tickets and assets would show creation dates years in the past, making dashboards and timelines look abandoned. This is solved by a small companion script seed_shift_dates.sql that runs immediately after import:
-- The newest date baked into this seed pack.
-- Update this constant each time the dump is regenerated.
SET @anchor = '2025-06-01';
SET @shift = DATEDIFF(CURDATE(), @anchor);
UPDATE glpi_tickets
SET date = DATE_ADD(date, INTERVAL @shift DAY),
date_mod = DATE_ADD(date_mod, INTERVAL @shift DAY),
closedate = DATE_ADD(closedate, INTERVAL @shift DAY),
solvedate = DATE_ADD(solvedate, INTERVAL @shift DAY);
UPDATE glpi_itilfollowups
SET date = DATE_ADD(date, INTERVAL @shift DAY),
date_mod = DATE_ADD(date_mod, INTERVAL @shift DAY);
UPDATE glpi_itiltasks
SET date = DATE_ADD(date, INTERVAL @shift DAY),
date_mod = DATE_ADD(date_mod, INTERVAL @shift DAY),
begin = DATE_ADD(begin, INTERVAL @shift DAY),
end = DATE_ADD(end, INTERVAL @shift DAY);
UPDATE glpi_itilsolutions
SET date_creation = DATE_ADD(date_creation, INTERVAL @shift DAY),
date_mod = DATE_ADD(date_mod, INTERVAL @shift DAY);
UPDATE glpi_computers SET date_mod = DATE_ADD(date_mod, INTERVAL @shift DAY);
UPDATE glpi_logs SET date_mod = DATE_ADD(date_mod, INTERVAL @shift DAY);
-- ( + other itemtypes with meaningful dates )@shift is computed at runtime as the number of days between the anchor and today. On each import the dump lands perfectly current, regardless of when it was generated. The only maintenance cost is updating @anchor when the dump is regenerated — a one-line change.
This script runs in under a second on the target dataset size.
Why not bake expressions like DATE_SUB(NOW(), INTERVAL 30 DAY) directly into the SQL? — Not valid mysqldump output; requires hand-crafting every INSERT row and breaks standard import tools.
Why not sed/regex preprocessing? — Fragile: risks matching version strings, IDs, or config values that happen to look like dates.
Dataset scale
Category | Count | Notes |
|---|---|---|
Entity hierarchy | 12 entities | Root → 3 subsidiaries → 3 departments each |
Users | ~120 | Varied profiles, spread across entities (see below) |
Computers | 600 | Desktops, laptops, servers |
Monitors | 120 | Linked to computers |
Network devices | 80 | Switches, routers, APs |
Printers | 40 | With consumables |
Phones / mobile | 60 | |
Software & licenses | 30 titles, 200 licenses | |
Tickets (incidents) | 350 | Full lifecycle, spanning 24 months |
Tickets (requests) | 200 | |
Problems | 25 | Linked to related incidents |
Changes | 15 | Pre/post/rollback steps |
Knowledge base articles | 150 | Across categories |
Service catalog forms | 20 | |
Contracts / suppliers | 15 / 10 | |
SLAs | 4 | 2 incident, 2 request |
Assignment rules | 8 | Category + entity based |
Dashboards | 3 | Central, assets, ITIL |
Total DB size: ~80–100 MB uncompressed, ~10 MB compressed. Import time: < 5 seconds.
What makes data representative
Volume alone does not make a convincing dataset. The quality constraints matter more than the counts.
Entity hierarchy — not flat. Entities have realistic names (Acme Corp → Paris HQ → IT Department), inherit some config, override others. Every asset and ticket belongs to a specific leaf entity, not all dumped into root. This makes entity filtering and per-entity dashboards non-trivial to explore.
Users — realistic names and emails, meaningful profile/entity distribution:
Profile | Count | Scope |
|---|---|---|
Super-admin | 2 | Root |
Local admin | 3 | One per subsidiary |
Technician | 20 | Mixed entities |
Manager | 6 | One per subsidiary |
Observer | 10 | Various |
Self-service (end user) | ~80 | Leaf entities |
Assets — not homogeneous. Each computer has an assigned user, a location, an OS, a hardware status. ~10% are in a non-nominal state (being repaired, retired, stored). Asset types are distributed so the CMDB dashboard shows a non-trivial breakdown.
Tickets — the most important part for a realistic feel:
Dates span 24 months (after the shift) so timeline charts are populated.
~60% closed with a solution, ~25% open/in progress, ~15% pending.
Closed tickets have at least one follow-up, one task, and a solution.
Priority distribution: ~50% normal, ~30% high, ~15% low, ~5% very high.
~20 tickets linked to a problem, ~10 to a change.
KB articles — use a small hand-written corpus of plausible IT support articles (password reset, VPN setup, printer troubleshooting…) rather than Lorem Ipsum. Realistic article content is what makes the KB feel usable rather than placeholder-filled.
How the dump is produced and maintained
The seed dump is generated once manually by setting up a GLPI instance and filling it with representative data, then running:
mysqldump glpi_seed > seed.sql
gzip seed.sqlIt is stored in install/seed/seed.sql.gz alongside install/seed/seed_shift_dates.sql. Both are versioned in the GLPI repository and updated on each major release (schema changes would break the dump anyway).
When a major GLPI version changes the schema, the seed dump must be regenerated. The date-shift script must also be audited for any renamed date columns.
Reset and re-seed
# Import seed data into sandbox DB
php bin/console sandbox:seed
# Wipe sandbox and re-import from scratch
php bin/console sandbox:seed --reset--reset truncates all GLPI data tables (keeps schema intact), re-imports seed.sql.gz, then runs seed_shift_dates.sql. Table truncation order follows reverse foreign-key dependency; a hardcoded list is acceptable for v1.
A seed marker in glpi_configs (context=sandbox_seed, name=seeded_at) prevents accidental double-import when --reset is not passed.
The reset is also available in Setup > Sandbox as a "Reset to demo data" button with a confirmation dialog.
Complexity estimates
Component | Effort |
|---|---|
| 1 d |
| 0.5 d |
Seed marker + idempotency guard | 0.5 d |
Admin UI reset button | 0.5 d |
Infrastructure total | 2.5 d |
Producing the seed dump (manual GLPI setup + data entry) | 3–4 d |
Grand total | ~6 d |
The dominant cost is producing the dump itself — setting up a realistic GLPI instance by hand takes time to get right. The infrastructure to import it and keep dates current is straightforward.
Snapshots (optional, future)
Snapshots allow the admin to save the sandbox state and restore it later. This is valuable for "before/after a risky change" testing.
Implementation sketch:
A snapshot is a mysqldump stored in
GLPI_VAR_DIR/sandbox/snapshots/<timestamp>.sql.gzsandbox:snapshot:create— dumps sandbox DB to filesandbox:snapshot:restore <id>— replaces sandbox DB from filesandbox:snapshot:list— lists available snapshots with metadata
Snapshots are not in scope for v1 but the architecture (sandbox as a separate DB) makes them trivially implementable later.
Cron / background jobs in sandbox
Cron tasks run outside of user sessions, against the global $DB (production). They must not be affected by sandbox mode. This is guaranteed by the session-only nature of the flag — $_SESSION['glpi_sandbox_active'] is not set in CLI/cron context.
However, some cron tasks (like sending emails for sandbox tickets) would be undesirable. A guard should be added to notification sending: if the sandbox DB is configured and the current request is writing to it, suppress external side effects (emails, webhooks, SMS). This requires DBmysql::isSandbox(): bool returning $this->sandbox ?? false.
Write protection / production safety
The most critical risk is accidentally writing production data while believing you are in sandbox mode. The mitigation:
The sandbox banner is always visible and prominently colored — never subtle.
Session::enterSandbox()logs an audit event (itemtypeSandbox, actionenter).DBSandbox::$sandbox = trueflag makes it possible for sensitive operations to assert!$DB->isSandbox()before running destructive changes to "real" data.The production
$DBobject is NOT reachable while in sandbox mode —$GLOBALS['DB']points toDBSandboxfor the duration of the request. No accidental fallback.
Summary of new files / changes
File | Change |
|---|---|
| Add |
| Add |
| Add |
| Add |
| New file, modelled on impersonate_banner |
| Include sandbox_banner after impersonate_banner |
| Add |
| New console commands: |
| Compressed seed dump, versioned release artifact |
| Post-import date-shift script |
| Admin configuration UI |
| Template for above |
| Generated at setup time |
Upgrade dry-run
Upgrading GLPI is the highest-stakes admin operation: a failed migration can corrupt the production database or leave it in a partially-migrated state that is hard to recover from. The sandbox makes it safe by providing a production-data clone to run the upgrade against first.
User story
As a GLPI administrator, before upgrading my production instance from version N to N+1, I want to run the upgrade migrations against a copy of my production data so I can verify that they complete successfully and that none of my plugins break.
How it works
The existing UpdateCommand (php bin/console database:update) reads the global $DB, which is initialized from config_db.php. Adding a --sandbox flag makes the command swap to DBSandbox before running any migration:
php bin/console database:update --sandboxThis is the only code change needed. No new migration infrastructure, no separate command. The flag simply calls DBConnection::switchToSandbox() at the start of UpdateCommand::execute() before any schema work begins.
The command output already includes per-migration status lines. When run against the sandbox, those lines confirm exactly what would run against production.
Suggested workflow surfaced in the UI
When GLPI detects that a newer version has been downloaded and the update is pending (the Update::isDbUpToDate() check), the update screen should display:
Before updating your production database, we recommend running
a dry-run against your sandbox.
[Run upgrade on sandbox first] [Upgrade production]"Run upgrade on sandbox first" triggers a background job (or redirects to a CLI instruction) that:
Syncs sandbox from production (to ensure the test is representative)
Runs
database:update --sandboxReports success / failure with the migration log
Only after a successful sandbox run does the "Upgrade production" button become the recommended action (though it is not blocked — the admin can skip).
Plugin compatibility check
Plugin upgrades are the most common source of post-update failures. The dry-run should also invoke each active plugin's migration scripts. This is already handled by database:update for plugins that properly implement the GLPI migration interface — the --sandbox flag covers this at no additional cost.
A post-run report should list:
Migrations that ran successfully
Migrations that failed (with the exception message)
Plugins that were active before the run but could not be loaded after (potential incompatibility)
What this does NOT cover
Code changes — the dry-run tests DB migrations, not PHP code compatibility. A plugin that works at schema level may fail at runtime with the new version. That requires running functional tests, which is out of scope here.
Rollback — the sandbox is disposable. If the dry-run fails, nothing was harmed; the sandbox can be re-synced from production for another attempt. No rollback mechanism is needed.
CLI summary
Command | Effect |
|---|---|
| Sync sandbox DB from production |
| Run pending migrations against sandbox DB |
| Run pending migrations against production DB (unchanged) |
The sync + update steps can be chained in a single CLI invocation for automation:
php bin/console sandbox:sync && php bin/console database:update --sandboxOpen questions
Multi-instance cloud: For SaaS where each tenant has their own GLPI, should sandbox be a different DB on the same server or a different server entirely? The spec supports both (host is configurable).
API in sandbox mode: The REST API uses session tokens. Should an API client be able to enter sandbox mode? Probably yes — useful for testing integrations. The entry mechanism would be a dedicated API endpoint
POST /api/sandbox/enter+POST /api/sandbox/exit.Sync frequency: Should sync be schedulable via the cron UI (like other GLPI cron tasks)? Yes — daily sync makes sense for most use cases.