nti/ticket-bundle
nti/ticket-bundle provides a lightweight ticketing layer for PHP applications, letting teams create, assign, and track support requests with statuses, priorities, and comments. It fits into existing Symfony/Laravel projects for simple helpdesk and issue-management workflows.
v3.2.0 is the first release worth onboarding against — multiple boards, a repaired inbox sync command, and ticket document storage change the day-to-day shape of the package enough that you should start here rather than with earlier versions.
composer require <vendor>/nti-tickets:^3.2
php artisan vendor:publish --provider="Nti\Tickets\TicketsServiceProvider"
php artisan migrate
Review the published config/nti.php (or config/tickets.php) before migrating — v3.2 introduces a boards collection where earlier versions had a single board block.Nti\Tickets\Console\Commands\SynchronizeBoardsInboxCommand — the command name (nti:ticket:synchronize-boards-inbox) tells you the intended entry point.Board / Ticket models and their relationships — these define what you actually query in your app.php artisan nti:ticket:synchronize-boards-inbox --board=default -v
Then confirm tickets landed (Ticket::where('board_id', ...)->latest()->first()) and that any attachments written to storage/app/... look right. That one loop — config → sync → query → inspect document — exercises every moving part.Board-scoped sync as the default mental model. With multi-board support, treat the command as per-board work rather than a global job. Schedule one entry per board so a slow or failing board doesn't stall the rest:
// routes/console.php
Schedule::command('nti:ticket:synchronize-boards-inbox')
->everyFiveMinutes()
->withoutOverlapping()
->runInBackground();
If the command supports a board argument/filter, prefer explicit per-board scheduling over a single sweeping run — it gives you per-board cadence (some inboxes need 1 minute, others hourly) and per-board failure isolation.
Wrap syncs in a queued job. Artisan commands are fine for cron, but a job gives you retries, backoff, and a place to hang observability:
class SyncBoardInbox implements ShouldQueue
{
public $tries = 3;
public $backoff = [30, 120, 600];
public function __construct(public int|string $boardId) {}
public function handle(TicketSynchronizer $sync): void
{
$sync->forBoard($this->boardId)->run();
}
}
Keep the job payload to a board identifier, never a hydrated model — boards get renamed/deleted and serialized models go stale.
Idempotent upserts keyed on the external ID. Every sync pattern in this package family relies on a stable remote identifier. Upsert on (board_id, external_id) rather than trusting local auto-increment IDs, so re-runs and overlapping schedules are harmless:
Ticket::upsert($rows, ['board_id', 'external_id'], ['subject', 'status', 'updated_at']);
Documents: decide ownership up front. v3.2 persists ticket documents. Two viable patterns:
Mirroring is usually right for anything user-visible; reference-only is fine for audit exports. Either way, route all writes through Storage::disk(config('nti.documents.disk')) so tests and local dev can swap to a fake.
Per-board credential isolation. Multi-board almost always means multi-tenant in practice. Keep credentials in config/env keyed by board slug, not on the board model, and resolve the client per board through the container rather than a singleton — a shared client is how you accidentally post board A's ticket to board B.
Test against a fake HTTP layer. Use Http::fake() with fixtures captured from one real sync per board shape. Assert on the upsert result and on Storage::disk(...)->assertExists(...) for the document path; that's the pair most likely to regress.
board_id column added to tickets and a config key that previously held a single board now holding a collection. Backfill board_id for existing rows before adding a NOT NULL constraint, and check whether old flat config keys still work — v3.2 notes "code cleaning," which often removes deprecated accessors silently. Grep your codebase for direct config reads (config('nti.board_id')) rather than going through the package's resolver.nti:ticket:synchronize-boards-inbox behaviour — custom loops, manual pagination, post-processing of half-synced inboxes — should be re-evaluated, not assumed compatible. Re-read the command's options; a bug fix often changes option semantics or exit codes.boards/{slug}/ prefix via a custom path resolver is the cheap fix.withoutOverlapping() is not optional. Inbox syncs are typically paginated and slow; overlapping runs plus upserts on the same key is a deadlock recipe on busy boards. If you can't use the scheduler lock, use a cache lock per board.Board::with('tickets.documents') on any dashboard listing. The package's "code cleaning" won't fix your queries.-v, tail the log channel the package writes to, and compare Ticket::count() before/after. If counts match but fields are stale, the problem is in your upsert key, not the transport.How can I help you explore Laravel packages today?