jeffersongoncalves/laravel-help-desk
Laravel Help Desk is a complete ticket management system for Laravel 11–13 with email integration. Create departments, assign operators, and manage ticket statuses, priorities, attachments, and notifications through a simple, configurable package.
Architecture fit. Unchanged in shape, and v1.13.0 strengthens the most important part of it. The package is a domain-in-a-box for Laravel: five or six Eloquent models (tickets, comments, attachments, departments, watchers, history) behind a single HelpDesk facade. That facade remains the stable seam — if we later replace the package with a commercial help desk or an in-house build, call sites (HelpDesk::createTicket, addComment, changeStatus) survive a rewrite and only the facade implementation changes. That is still the single biggest argument in favour of piloting it.
The second architectural fact is the two-driver design, and this release does not change it. driver: database gives full capability but requires the satellite application to hold credentials for the support database. driver: api gives a signed-HMAC HTTP boundary where the satellite holds only a shared secret, but supports the end-user side only — operator actions (closeTicket, assignTicket, addNote) throw before making a request, Eloquent scopes don't exist because there are no local tables, and attachment reads throw because there is no disk to serve from. The release note "Reads remain scoped to the requester" is an explicit confirmation that the capability boundary is intact: a satellite can now file a ticket with a company and read back its company_name, and still cannot run an agent console. That is not a minor caveat; it is the decision that shapes the whole deployment topology.
The third strength — no foreign key to users, identity snapshotted into metadata at write time — is now the design pattern the package is deliberately extending. company_name lands in metadata.company.name alongside the requester's name, $ticket->company_name reads it with a fallback to company_id, and there is still no companies table. This is unusually mature for a package this size: the support database can remain a standalone service that knows nothing about any application's user or company tables, tickets from several products and several customers can coexist, and history does not silently rewrite itself when a user is renamed, a company is rebranded, or a tenant is decommissioned. Two consequences a TPM should internalise now:
company_id is a label, not a relationship. There is no referential integrity, no company model, no cascade, and — critically — no company-level visibility enforcement. "Which tickets can an agent see?" is not answered by this package. If we need tenant segmentation in the agent console, that is our build, not theirs.No migration needed is stated explicitly), so any historical display cleanup is a data edit we own.Integration feasibility. The release is additive and non-breaking: one new optional createTicket() argument, one new field on the API POST and on TicketResource, docs, and CI. No migration, no config change required, no deprecation. Upgrade effort from v1.12.x is a composer update plus a smoke test. Semver has been respected, which is itself a useful signal about upgrade hygiene.
The install path remains conventional (composer require, publish config, publish migrations, migrate, add traits to User), with the same invasive step: HasTickets on requester models and IsOperator on agent models means editing the application's User class — a shared file in any monolith and a coordination point across teams. The new ability to map a differently named column (e.g. empresa_id) via an accessor is the same pattern as the existing toHelpDeskSnapshot() override, and it means a non-standard schema is not a blocker — but it is code we write and own.
The genuinely sharp edges are unchanged, and one is now slightly wider:
$ticket->user, $comment->author, $attachment->uploadedBy, $entry->performer, $row->watcher all raise Class "..." not found if the stored morph type names a class the current app doesn't have. Every read of a person must go through requester_name, author_name, performer_name, resolvedAuthor() and friends. This is a coding standard, not a one-off fix — it needs to be in the PR checklist or it will be violated by the first developer who joins after the pilot. Note the company snapshot has no equivalent trap because it stores scalars, not morphs; that is a point in its favour.Relation::enforceMorphMap(['app-a-user' => ...]) will collide user #5 across applications. One-line AppServiceProvider change, very high blast radius if forgotten.company_id is a string with no uniqueness guarantee across applications. Two products both using company_id = "42" will produce tickets that look like one customer. The new README section documents setting and querying company_id and flags a null company caveat, but it does not mandate a namespacing convention. That decision is ours.InvalidStatusTransitionException on illegal transitions; closed tickets reopen only to Open. Good domain modelling, but any UI or import script assuming free-form status setting fails at runtime, not at design time.Technical risk. The dominant risk was, and remains, bus factor and ecosystem signal — one maintainer, no vendor to call, no peer community of any size. But v1.13.0 is a genuine de-risking datapoint on three axes: the project is shipping (issue numbers #80–#82 and a full compare link imply real commit and review history, so the earlier "verify the release date is real" concern is largely closed), the changes are semver-clean and migration-free, and the new standardized tests workflow plus Dependabot config materially lowers the cost of the fork contingency — a fork we inherit with CI and a test suite is far cheaper to carry than a fork with neither. Tempering that: github-actions auto-merge on a single-maintainer repo is a supply-chain consideration worth noting, and auto-merged dependency bumps are not the same as reviewed domain changes. The star count and dependent count should still be re-verified against the repository, because this changelog tells us nothing about adoption.
| Risk | Severity | Mitigation |
|---|---|---|
| Single maintainer, abandonment | High (was High; slightly reduced by active releases + CI) | Facade seam + fork rights under MIT; CI/test workflow now inherited with any fork; ~5 days/quarter contingency |
| Laravel/PHP version support unverified | High | Pin major version; verify support matrix against our LTS target before committing |
Cross-company data conflation (company_id unnamespaced) |
Medium-High (new) | Namespacing convention in the ADR (e.g. app-key:company-id), enforced by app-side validation |
| Agent visibility not scoped by company | Medium-High (new) | Build the scoping ourselves in the agent console; do not assume the package provides tenancy |
| Morph-relation fatal reads in app code | Medium | Lint rule / code review checklist; accessor-only convention documented |
| Two-driver capability asymmetry misunderstood | Medium | Architecture decision record; explicit statement of what each satellite can and cannot do |
| Email inbound integration depth unproven | Medium | Spike before committing to "email integration" as a delivered feature |
| Reference number generation under concurrency | Medium | Load test ticket creation; confirm DB-level uniqueness on reference_number |
Attachment disk defaults to local |
Medium | Configure shared/S3 disk for any multi-node or multi-app deployment |
| Legacy tickets display company id, not name | Low | Accept, or script a one-off metadata.company.name backfill; decide at cutover |
Key questions. Revised where v1.13.0 has moved the answer.
driver and scope_to_app for every application.api-driver satellite is permanently read-and-create only — unchanged by this release.company_id namespacing convention, and who validates it at the facade boundary? Without one, cross-product tenant collision is a data-integrity incident waiting to happen.Stack fit. A natural fit for a Laravel-first estate. If the applications in scope are Laravel on the same major version, integration is idiomatic: no new runtime, no new language, no new deployment unit, no separate service to operate. If the estate is polyglot, the signed API driver is the escape hatch — any non-PHP satellite can speak HMAC-signed HTTP to a Laravel central support app. v1.13.0 improves that path slightly: POST tickets accepts company_id and company_name, and integer company_id is cast to string, which removes a class of type-mismatch friction for a typed client. Worth flagging unchanged: the API driver's inbound usefulness is limited to the end-user side, so a non-PHP satellite can file tickets but cannot run an agent console.
Migration path. Three lanes, in ascending cost, with one new sub-task:
database driver owning the schema; satellites on api driver. One-way ETL per product: map old tickets into createTicket with preserved created_at, accept that history before cutover is a backfill with synthetic system comments. Added: decide per source system whether legacy tickets get a company_name snapshot or are left displaying the id — and if backfilled, whether the backfill is worth the write against an audit table. Budget one iteration per source system; legacy status vocabularies rarely map one-to-one onto Open / InProgress / Resolved / Closed, and transition validation will reject the unmappable ones.Critical sequencing rule for any lane touching more than one app: the schema owner goes first and alone. Central application publishes config and migrations, runs them, and proves the schema is stable before any satellite is configured. Satellites then install with HELPDESK_DB_CONNECTION (or HELPDESK_DRIVER=api) and explicitly do not run migrations. v1.13.0 makes this easier — it needs no migration at all — but it does not relax the rule, and the version skew it implies (some satellites on 1.12, some on 1.13) is harmless precisely because the change is additive.
Compatibility. Three axes now, two of them unchanged. First, Laravel and PHP version support — still unverified, still the most likely cause of an early, cheap failure. Second, our own authentication and user model: the trait-based integration assumes a conventional App\Models\User; if permissions live in a custom guard, a separate Agent model, or an SSO-only identity with no local record, IsOperator and the snapshot mechanism need adaptation, including a toHelpDeskSnapshot() override. Third, new: our company/tenant schema. The README explicitly supports mapping a differently named column (empresa_id) via an accessor, so a non-standard schema is not a blocker — but it is integration code with a test obligation, and it must also cover the case where a ticket has no company at all (the documented null caveat). Confirm what the agent console should display for null before UAT, not during it.
Notification delivery remains the forgotten compatibility point. notifyRequester() resolves through the model when possible and falls back to an on-demand mail to the snapshot address, so outbound email configuration, from-address reputation, and queue workers are all in scope. If mail is synchronous today, ticket creation will feel slow and needs a queue.
Sequencing. Recommended order, with the release folded in:
company_id/company_name and one without, and specifically test the email inbound path. Confirm the version matrix. Output: go/no-go with a real effort estimate rather than an estimated effort.scope_to_app policy — and now company_id namespacing, the null company display rule, and whether company-level visibility is in scope or explicitly deferred.LogTicketHistory listener is registered — history silently absent is a nasty late discovery), attachment storage on S3.Relation::enforceMorphMap into AppServiceProvider::boot(), add the review checklist item. Do this before the second developer touches the code.api driver. Deliberately pick the least complex product. Goal: prove the signed-API path, secret configuration, company_id/company_name round-trip, and the capability boundary in the smallest possible blast radius.database-driver satellites, a documented "no migrations" rule.open(), overdue(), unassigned(), byPriority()), SLA surfaces from due_at, and — if in scope — company-scoped filtering built by us.Maintenance. Low steady-state code maintenance; non-trivial upgrade maintenance, though v1.13.0 is the good case — additive, no migration, no config change, semver-clean. The package surface we touch stays narrow (facade calls, two traits, one config file), MIT licensing preserves the fork option, and the standardized tests workflow plus Dependabot config reduce the cost of exercising it. Budget realistically: a dependency review each Laravel upgrade cycle, plus a named owner. The maintenance cost that catches teams out is not the package — it's the surrounding operations: attachment storage growth and lifecycle policy, outbound mail deliverability, queue monitoring for notifications, and the schema. Someone has to own "the tickets table grew 40% this quarter," and someone has to own the company_id convention as new products onboard.
Support. Two distinct support populations needing different answers. End users need to know how to file and track a ticket — v1.13.0 changes nothing here. Agents need to understand the state machine, because the package enforces it and will reject workarounds; the failure mode to pre-empt is an agent trying to move a Closed ticket to InProgress and being refused — that's a policy the package has already made for us and needs to be communicated as a deliberate rule, not a bug. Add a third, smaller population: whoever maintains the company mapping. A mis-set empresa_id accessor silently produces blank or wrong company names on every new ticket, and no one notices until a report is wrong. Internal first-line support also needs a documented escalation path to the fork-contingency owner, because there is no vendor to call.
Scaling. The read path scales like any Laravel app — Eloquent over a tickets table. The concerns are specific and predictable:
status, assigned_to, due_at, and app_key before volume arrives, not after. company_id will become a common filter — evaluate an index once reporting demand appears, but don't add it speculatively; it lives in a JSON column and the query shape matters.api topology. If it's down, satellite users cannot file tickets. Usually acceptable for an internal help desk — but an accepted risk, not an unexamined one.HD-00001 is computed by counting rows, that's a real collision risk at volume.Failure modes. Ranked by likelihood × impact, with the two new entries marked.
| Failure | Symptom | Detection | Response |
|---|---|---|---|
| Morph relation read directly | Class not found fatal on a ticket page |
Code review + static analysis | Fix at call site; accessor-only rule |
| Cross-app morph collision | Wrong person's name on a ticket | Manual spot-check | Retroactive morph map — data repair required, so prevent it |
Cross-company company_id collision (new) |
Two different customers appear as one in reports | Reporting discrepancy, found late | Namespacing convention at the facade boundary; retroactive repair is a data edit |
| Company visibility assumed but absent (new) | Agent sees another tenant's tickets | Security review / UAT | Build scoping explicitly; treat as an accepted gap until built |
| Satellite ran migrations | Table-already-exists errors, partial schema | Deploy-time failure | "No migrations" per satellite; deploy checklist |
| History silently empty | No audit trail, discovered at audit time | Verify at slice 3, not at launch | Confirm LogTicketHistory registration |
| Attachment URL throws | Images broken on satellite | UAT | Shared disk / S3; getTemporaryUrl requires S3, not local |
| Illegal status transition | Agent blocked mid-workflow | Agent complaint | Communicate the state machine as policy; provide reopen path |
| Mail not delivered | Requester never notified | Bounce monitoring | Queue workers, from-address reputation, SPF/DKIM |
| Version skew across satellites | Inconsistent company_name presence in responses |
Integration test | Additive release, so tolerate skew; pin per app and roll forward |
| Upstream abandonment | Security patch unavailable | Dependency review | Fork under MIT; CI now inherited; ~5 days/quarter contingent |
Secret leak on api driver |
Attacker impersonates any user of one app | Secret rotation | Two-secret rotation reduces flag day, not exposure window; HTTPS mandatory since HMAC gives authenticity, not confidentiality |
Ramp-up. Roughly one sprint for a Laravel-fluent developer to be productive, and v1.13.0 slightly reduces the reading burden by documenting the multi-company path in one place. Cost is concentrated in two places: teaching the accessor-only rule for person reads (a 30-minute briefing that prevents a class of production fatals), and teaching the multi-app topology — who owns the schema, why satellites don't migrate, why morph aliases exist, and now what company_id means and does not mean. That second one is institutional knowledge that evaporates when the person who read the docs leaves. Write it down.
For operators and support staff, ramp-up is a half-day on the ticket lifecycle, the department/enquiry model, and — new — how to read a company field that may be blank, an id, or a name depending on ticket age. That inconsistency is small but it generates support questions, so it belongs in the agent one-pager.
For the TPM, the ramp-up cost is the evaluation itself. This package is cheap to trial and cheap to abandon, but only if we hold the facade seam and resist reaching past it into the models. v1.13.0 improves the picture modestly and in the right direction — active shipping, CI, snapshot-consistent design, no breaking change — while leaving the two questions that actually decide the programme untouched: the Laravel version matrix and the depth of email integration. It also surfaces a question the earlier assessment didn't have: whether company is a label or a tenancy boundary. The package gives us the label for free. If the business thinks it's buying the boundary, we will build the boundary ourselves, and that should be sized before any steering-group commitment — which is why the recommendation stands: a bounded two-week spike on v1.13.0 with a pre-agreed go/no-go, gated on the version matrix, email depth, and a decision on what company scope has to mean.
How can I help you explore Laravel packages today?