snipe/snipe-it
Snipe-IT is a free, open-source IT asset management system built on Laravel. Track hardware, software licenses, accessories, and consumables; manage checkouts, assignments, audits, and reporting through a self-hosted web UI and REST API. Built for IT teams needing lifecycle visibility and accountabi...
First, reset expectations: this is not a Composer package you require into an existing Laravel app. Snipe-IT is a complete Laravel application (asset/license/consumable tracking with checkout workflows, depreciation, auditing, labels, LDAP/SAML). The Dependents: 0 figure is accurate and expected — there is no library surface to import. You "leverage" it in exactly two ways:
For 90% of teams, option 1 is the right call. Option 2 drags you into AGPL obligations and upstream merge conflicts (see Gotchas).
PHP 8.2.0 is the hard floor; 8.4+ is what upstream recommends and what they test against. v8.8.3 specifically clears PHP 8.4/8.5 deprecation notices, so running 8.4 no longer spams storage/logs/laravel.log with deprecations on every request. If your fleet is still on 8.1 or has 8.2 pinned to an EOL image, you cannot take this line — plan the PHP upgrade before you plan the Snipe-IT upgrade. Upstream has signalled future features will require 8.3+, so treat 8.3 as the realistic near-term target and 8.4 as where you want to be.
# docker-compose.yml
services:
app:
image: snipe/snipe-it:v8.8.3@sha256:<digest> # pin tag AND digest — see Gotchas
ports: ["8080:80"]
env_file: .env
volumes:
- snipeit-config:/config
- snipeit-uploads:/var/lib/snipe-it/public/uploads
depends_on: [db]
db:
image: mariadb:11
environment:
MARIADB_DATABASE: snipeit
MARIADB_USER: snipeit
MARIADB_PASSWORD: change-me
MARIADB_ROOT_PASSWORD: change-me-too
volumes:
- snipeit-db:/var/lib/mysql
volumes:
snipeit-config: {}
snipeit-uploads: {}
snipeit-db: {}
Set the non-negotiables in .env: APP_URL (must match the real external URL — it drives API URLs, email links, SAML ACS), APP_KEY, APP_ENV=production, APP_DEBUG=false, APP_TIMEZONE, and the DB_* block matching the service above. Then browse to /setup, create the admin, and you're in.
Both the snipe/snipe-it (Debian/Ubuntu) and Alpine image variants are published per release. v8.8.2's Alpine tags were rebuilt as v8.8.3 within minutes of release to pick up Alpine's patched PHP — if you grabbed :latest or :8.8.2 during that window, verify which bits you actually have.
The bare-metal path (git clone, composer install --no-dev, php artisan key:generate, php artisan migrate --seed) works but you inherit PHP extension, permissions, and upgrade chores. Prefer Docker or a maintained image.
| Path | Why you care |
|---|---|
routes/api.php |
The entire public contract. Read this before writing any integration. |
app/Http/Controllers/Api/ |
Endpoint behaviour, validation, edge cases. |
app/Http/Transformers/ |
Fractal transformers defining exact JSON response shapes. |
app/Models/ |
Asset, AssetModel, User, Location, Statuslabel, License, LicenseSeat, Consumable, Accessory, Component, Actionlog, Setting. |
app/Models/Traits/Checkoutable.php |
The checkout/checkin/audit state machine — do not reimplement it. |
app/Console/Commands/ |
Everything in the snipeit:* namespace (`php artisan list |
composer.json |
require.php is the authoritative version floor for the tag you're on. |
.env.example |
Authoritative list of supported env vars. |
curl -s "$SNIPEIT_URL/api/v1/hardware?limit=5&sort=id&order=asc" \
-H "Authorization: Bearer $SNIPEIT_TOKEN" \
-H "Accept: application/json"
The response is {"total": 412, "rows": [...]} — note it is not Laravel's default paginator shape.
The highest-value first project is almost always: CSV import of your existing spreadsheet → bulk checkout to users → print asset labels. That validates your data model (models, status labels, locations, custom fields) before you build anything automated on top of it.
Keep Snipe-IT as an isolated system of record. Build your reconciliation logic in your app, in your repo, under your licence.
// config/services.php
'snipeit' => [
'url' => env('SNIPEIT_URL'),
'token' => env('SNIPEIT_TOKEN'),
],
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Generator;
class SnipeIt
{
public function assets(array $query = []): array
{
return $this->client()->get('api/v1/hardware', $query)->throw()->json();
}
/** Stream every asset without loading the whole inventory into memory. */
public function eachAsset(int $chunk = 500): Generator
{
$offset = 0;
do {
$page = $this->assets([
'limit' => $chunk, 'offset' => $offset,
'sort' => 'id', 'order' => 'asc',
]);
yield from $page['rows'];
$offset += $chunk;
} while ($offset < $page['total']);
}
public function checkout(int $assetId, int $targetId, string $targetType = 'user'): array
{
return $this->client()
->post("api/v1/hardware/{$assetId}/checkout", [
'checkout_to_type' => $targetType, // user|location|asset
'assigned_to' => $targetId,
])->throw()->json();
}
public function checkin(int $assetId, array $payload = []): array
{
return $this->client()
->post("api/v1/hardware/{$assetId}/checkin", $payload)->throw()->json();
}
protected function client(): PendingRequest
{
return Http::baseUrl(config('services.snipeit.url'))
->withToken(config('services.snipeit.token'))
->acceptJson()
->timeout(15)
->retry(3, 300, fn ($e) => $e instanceof \Illuminate\Http\Client\RequestException
&& in_array($e->response->status(), [429, 500, 502, 503]), throw: false);
}
}
Reconciliation job (HR/IdP is the source of truth for people; Snipe-IT for hardware):
public function handle(SnipeIt $api): void
{
foreach ($api->eachAsset() as $asset) {
if ($asset['assigned_to'] === null) continue;
if (! $this->hr->stillEmploys($asset['assigned_to']['id'])) {
$api->checkin($asset['id'], ['note' => 'Auto-checkin: leaver detected']);
}
}
}
Two rules that keep this robust: always use the API for checkout/checkin/audit (never direct DB writes — the API updates status labels, fires events, and writes Actionlog entries), and make the job idempotent by keying off asset_tag/serial, not local row IDs.
Only when you genuinely need behaviour the API can't express (custom report endpoints, bespoke approval flow, new checkout guard rules). Follow the grain of the codebase or upgrades will hurt:
routes/api.php → ApiController subclass → Fractal transformer in app/Http/Transformers/ → FormRequest in app/Http/Requests/.// routes/api.php
Route::middleware('auth:api')->group(function () {
Route::get('v1/reports/audit-gaps', [ReportsController::class, 'auditGaps']);
});
app/Notifications/ and are wired through config/ + the Settings model — don't hardcode mail logic.app/Console/Commands/ and gets registered in the scheduler, mirroring snipeit:expiring-alerts.composer.json. A fork that pins below the upstream floor turns every merge into a conflict in composer.json/composer.lock.There is no rich audit-log API. If you need "who changed what, when", point a read-only DB user at action_logs (and checkouts) from a separate reporting app or replica. Never write from there.
Asset ──belongsTo──> AssetModel ──belongsTo──> Manufacturer
│ └──belongsToMany──> CustomFieldset ──> CustomField
├──belongsTo──> Statuslabel (meta: deployable|pending|undeployable|archived|lost)
├──belongsTo──> Location, Supplier, Company
├──morphTo───> assigned_to (User | Location | Asset)
└──hasMany───> Actionlog, Checkout, AssetMaintenance
License ──hasMany──> LicenseSeat ──benongsTo──> User|Asset
Consumable / Accessory / Component → independent checkout flows
.env from your secret manager; nothing hand-edited on the box.* * * * * php /var/www/html/artisan schedule:run — without this, expiring-licence alerts and expected-checkin reminders silently never fire.QUEUE_CONNECTION, run a worker for snipeit:expiring-alerts and import jobs; the default sync will make big imports time out.php artisan snipeit:backup (or a DB dump + the uploads volume). Uploads are not in the DB and are the thing everyone forgets.php artisan test requires a separate testing database via .env.testing; the suite leans on transactions and will happily wipe your dev data if you point it at the wrong DB. For companion apps, write contract tests against a disposable Snipe-IT container in CI — that catches upstream API changes on upgrade. Run the same suite on 8.2 and 8.4 if you maintain a fork, since deprecation cleanups land in patch releases.
AGPL-3.0 is the biggest one. Network-use copyleft: if you modify Snipe-IT and let users interact with it over a network, you must offer them the corresponding source. Internal-only deployments are usually fine; embedding it in a product you ship or host for customers is a legal conversation. The companion-app pattern (Pattern A) keeps your proprietary code in a separate process with a clean boundary — this is a real architectural driver, not just a preference.
PHP 8.2 is a hard floor from this line onward — 8.4+ is the recommended target. This is the change that bites hardest in practice, because the failure mode is a container that won't boot or a composer install that resolves nothing, not a nice error in the UI. Audit your base images and CI runners before upgrading. Upstream has explicitly warned that 8.3+ will be required for forthcoming features, so do not stop at "just enough to boot" — you'll repeat the exercise in two releases.
Releases get re-tagged. v8.8.2 was published and then re-cut as v8.8.3 within minutes because Alpine shipped patched PHP mid-release. Tag names are not immutable in this project. Pin digests in production, keep a changelog of which digest you're running, and always re-pull rather than assuming a tag's contents. If you build a derivative image, rebuild it on every patch release.
Patch releases carry deprecation noise fixes, not just features. v8.8.3's only functional change outside the PHP rebuild is clearing PHP 8.4/8.5 deprecation notices. That matters: deprecation spam in storage/logs/laravel.log masks real errors and can blow up log ingestion costs. If your monitoring is drowning in E_DEPRECATED, take the patch rather than muting the channel.
Settings live in the database, not config/. Setting::getSettings() is cached. After changing anything in Admin → Settings, clear the cache; from CLI, remember the web process may have cached a different view. APP_URL mismatch is the #1 cause of broken email links, wrong API pagination URLs, and SAML failures.
API tokens inherit the user's permissions. A superadmin token in a cron job is a privilege-escalation footgun. Make one integration user per integration, drop it to the narrowest role that works, and rotate tokens on offboarding.
Response shape differs from Laravel's default. Expect {total, rows} with limit/offset/sort/order/search — not data/meta/links. Don't reach for ->collect()->paginate() idioms.
Rate limits are real. Default throttling returns 429; the retry() above handles it, but bulk imports should be queued and chunked, not looped synchronously in a request.
Status labels have meta values (deployable, pending, undeployable, archived, lost) that the checkout logic depends on. Creating custom labels with the wrong meta produces assets that silently refuse to check out.
Never write to assets / checkouts directly. You'll bypass Actionlog, leave assigned_to polymorphic columns inconsistent, and skip status-label transitions. The API exists precisely to keep that invariant.
Uploads and permissions. public/uploads (logos, asset images, avatars) must be writable and persisted outside the container; php artisan storage:link for anything served through storage. Losing that volume on upgrade is a classic incident. Also watch FILE_UPLOAD_MAX_SIZE vs upload_max_filesize/post_max_size.
Database choice. MariaDB/MySQL is the supported path — SQLite is for evaluation only. On older MySQL with utf8mb4, watch index key-length limits during migrations.
Upgrades are migrations, and upgrades are not always reversible. Pin a release tag (and digest), read the release notes (there have been security advisories in older lines — run composer audit on any fork), back up DB and uploads, then use php artisan snipeit:upgrade. Rehearse on a restored copy first; a failed migration mid-upgrade is a bad afternoon. Read the release body, not just the version number — the PHP floor and re-tag notes only ever appear in prose.
Debugging. APP_DEBUG=true locally, storage/logs/laravel.log and LOG_CHANNEL for the rest; php artisan config:clear cache:clear before believing any weird behaviour after an .env edit. php artisan about (shows PHP version and extensions at a glance — your first stop for "does this image meet the floor?"), php artisan route:list --path=api, and composer check-platform-reqs answer most "why won't it boot / why is this 404" questions in seconds.
Extension points worth knowing: model observers (app/Observers) for side effects on checkout, custom Fractal transformers to shape API output for your consumers, app/Notifications for Slack/Teams/webhook-ish delivery, and config/ overrides. There is deliberately no API for creating custom fields — configure them in the UI and treat that configuration as versioned infrastructure (document it, or script it against the DB in a controlled migration in your fork only).
Scheduling is opt-in and silent when absent. If alerts, expected check-ins, or LDAP sync "just don't happen", check cron before you check code.
How can I help you explore Laravel packages today?