Issue Types

View on GitHub

Issue Types

Issue types are a per-project resource backed by a real issue_types database table (App\Models\IssueType) — not a fixed backend enum, and not a plain string on the issue like the old Issue.type field used to be. Every project gets a starter catalog of 16 system types (Task, Feature, Story, Bug, Epic, Spike, Chore, Improvement, Incident, Security, Infrastructure, Research, Experiment, Documentation, Design, AI Task) seeded lazily the first time they're needed, and project owners/admins can rename, recolor, describe, restrict, add, or delete any type — including the seeded ones (system types can be edited but never deleted) — from Settings → Issue Types. Each type owns its own workflow (a small state machine of statuses and legal transitions between them), can be marked as allowing sub-issues (only an Epic-shaped type would set this), can require specific issue fields to be filled in before an issue of that type can be saved, can restrict who's allowed to create one, and can define reusable templates that prefill an issue's description/labels/priority.

Guides, in the order you'd actually need them

  1. Add a new default issue typeIssue TypesAdd a new default issue typeWorked example: adding a 17th starter type, Compliance, to the 16 every project is seeded with. This only changes what a brand-new project starts with — an… — worked example adding a 17th starter type, Compliance, to the catalog every new project is seeded with.
  2. Add a workflow status categoryIssue TypesAdd a workflow status categoryA workflow status's category isn't free text — it's a fixed, three-value enum (todo/inprogress/done) shared by every issue type's workflow, used to color-g… — worked example extending the fixed todo/in_progress/done category enum with a fourth bucket, blocked.
  3. Extend required fieldsIssue TypesExtend required fieldsAn issue type's requiredfields is a fixed set of keys, each mapped to the actual Issue create/update request field it enforces non-emptiness on. Worked exa… — worked example adding a new required-field option (parent, "must be a sub-issue") to the fixed set an issue type can demand.
  4. Add a new list columnIssue TypesAdd a new list columnThe issues table's toggleable columns are driven by a single registry, resources/js/utils/issueTableColumns.ts — it's the one place that defines which colu… — worked example adding a Reporter column to the issues table, using the column-registry this feature introduced.

The architecture in one paragraph

issue_types (migration 2026_09_12_140000_create_issue_types_table.php) has project_id, name, icon (a lucide-react component name), color, description, is_system, allows_children, is_top_level, required_fields (JSON array), restricted_role_types (JSON array), sort_order, unique on (project_id, name). The model serializes itself into the exact camelCase shape resources/js/types/IssueTypes.ts declares (see IssueType::toArray()), which is why no controller needs a mapping helper of its own and why a type nested inside an issue looks identical to one sent as a top-level page prop. App\Repositories\IssueTypeRepository and App\Services\IssueTypeService follow this codebase's usual Controller → Service → Repository split (see ../architecture/02-backend-layered-architecture.mdArchitecture & project technicalsBackend layered architectureController → Service → Repository, with no exceptions and no shortcuts — a controller never builds an Eloquent query, and a repository never contains busin…): IssueTypeService::ensureSystemIssueTypes() seeds the 16 starter types — each with a default three-status workflow — exactly once per project, tracked by a projects.issue_types_seeded_at timestamp, the same pattern LabelService::ensureSystemLabels() uses (see ../labels/README.mdLabelsIssue labels are a per-project resource backed by a real labels database table (App\Models\Label) — not a fixed backend enum. Every project gets a starter…) and for the same reason: "insert whatever's missing" on every read would silently resurrect a type an owner deliberately deleted. Unlike labels, though, an issue's type is a real foreign key (issues.issue_type_id), not a name string — renaming a type in Settings is instantly reflected on every issue that references it, and a type can't be deleted while any issue still uses it (IssueTypeService::deleteIssueType() throws a ValidationException in that case; a system type can never be deleted at all).

Each issue type owns its own workflow: workflow_statuses (name, color, category — one of todo/in_progress/done, cast to App\Enums\WorkflowStatusCategory — and is_initial) and workflow_transitions (from_status_idto_status_id), both scoped to issue_type_id. App\Services\WorkflowService owns add/rename/delete for statuses and add/remove for transitions; IssueController::update calls WorkflowService::assertTransitionAllowed() before accepting a status change, so an issue can only move to a status its type's workflow actually allows moving to. Exactly one status per workflow carries is_initial — the one new issues start in; promoting another demotes the previous holder, which is what WorkflowService::setInitialStatus() (route projects.issue-types.statuses.initial) does from the workflow modal. That modal draws the workflow as an ordered flow of draggable cards — WorkflowService::reorderStatuses() (route projects.issue-types.statuses.reorder, declared before the {status} route so reorder isn't swallowed as a status id) rewrites sort_order to the dropped order, and a status no transition leads out of is flagged with a "No transitions" chip so a dead end in the graph is visible without reading the matrix below it.

An issue's status is changed by sending workflow_status_id, which is the only way to reach a custom status like "In Review" — the issue detail sidebar builds its picker from the type's own statuses, filtered down to the ones a transition actually leads to from the current one. The legacy issues.status enum (open/in_progress/closed) still exists and is kept in sync in both directions for backward compatibility: IssueTypeService::legacyValueForWorkflowStatus() collapses a picked status back onto the enum by category, and IssueTypeService::resolveWorkflowStatusForLegacyValue() maps the other way — to the best-fit status by category, not by exact name, so a request that still sends the old status field resolves to something sensible even for a type whose workflow has been customized away from the three default statuses. Every system type's default workflow is a simple three-status board (To Do/In Progress/Done) with every status able to transition to every other one (a full mesh) — this matches the pre-Issue-Types behavior, where any IssueStatus value could be set at any time; a custom, restrictive workflow is something a project has to build deliberately from Settings → Issue Types → Manage workflow.

On top of the built-in fields every issue has, a type can define its own custom fields: issue_type_fields (label, type — one of text/textarea/number/date/select/checkbox/url, cast to App\Enums\IssueFieldType — plus options for a choice field, placeholder, is_required and sort_order), managed from Settings → Issue Types → Manage fields. Values live in issues.custom_fields, a JSON map keyed by field id rather than by label, for the same reason the type itself is a foreign key: renaming a field never orphans the values already stored against it. IssueTypeFieldService::sanitizeValues() is the gate on the way in — it drops values addressed to a field that no longer exists or belongs to another type, and coerces each value to its field's shape (a choice outside the option list, or a non-numeric number, is discarded rather than stored). On update the incoming map is merged onto what the issue already holds with array_replacenot array_merge, which would renumber the numeric field-id keys — so a partial save from the sidebar never wipes the fields it didn't send.

Everything a system type ships with lives in App\Support\SystemIssueTypeDefaults — its own workflow statuses, the child types it accepts, a starter template, and its custom fields — so a Bug really does move Reported → Triaged → Fixing → In Review → Fixed (or straight to Won't Fix from anywhere) and really does ask for steps to reproduce, while an Incident tracks severity and a postmortem link. Transitions are derived from the status order rather than listed by hand (IssueTypeService::defaultTransitionPairs(): a step forward, a step back, and a jump to any terminal status from anywhere). No seeded field is is_required, because quick-add creates an issue from a title alone and a required field would break that for the whole type; marking one required is a deliberate per-project choice. Because these defaults arrived after projects had already been seeded, they are versioned: projects.issue_type_defaults_version against IssueTypeService::DEFAULTS_VERSION. A project behind the current version has applyTypeDefaults() run on its next read, which is strictly additive — it never creates a type that was deleted, never removes a status, transition, template or field, and never overwrites one that already exists under the same name. The single exception is a type still carrying the untouched stock three-status board: that has never been customized, so it is swapped for the type's own workflow and every issue is moved onto the new status sharing its old one's category (replaceGenericWorkflow()).

Permissions follow the same Permission enum / ProjectPolicy / RoleService tier pattern as labels: ISSUE_TYPES_VIEW/CREATE/UPDATE/DELETE and WORKFLOW_UPDATE (view: owner/admin/member/viewer; every mutation: owner/admin only — see ../permissions/README.mdRoles & permissions (RBAC)Every project has its own role-based access control: four built-in system role tiers (Owner, Admin, Member, Viewer) plus any number of project-defined cust… if you need to add a new permission of your own). On top of that project-wide gate, each issue type can carry its own restricted_role_types (a JSON array of owner/admin/member/viewer values) — App\Policies\IssuePolicy::createOfType() is a second, narrower check applied only when actually creating an issue of that specific type, on top of the general issues.create check; an empty restricted_role_types array means "no extra restriction, anyone who can create issues at all may use this type."

Hierarchy reuses the pre-existing issues.parent_id column — nothing new there — gated by three per-type settings. allows_children decides whether an issue of this type can be a parent at all. The issue_type_children pivot (IssueType::allowedChildTypes()) narrows which types may be nested underneath it; an empty pivot set means unrestricted, so only configuring at least one row starts restricting. is_top_level decides the other direction: a type with it turned off exists only as a sub-issue and cannot be created as a root row, which is what keeps the "New issue" picker down to the handful of types a project actually starts work from (Task, Feature, Story, Bug, Epic out of the box — every other system type is seeded sub-issue only). All three are toggled from Settings → Issue Types, and App\Services\IssueService::assertValidParent() enforces every one of them — plus same-project, no self-parenting, and no cycles — whichever endpoint (create or update) is setting parent_id. An issue's own detail view lists its children and creates new ones through resources/js/Components/Organisms/IssueChildrenPanel/IssueChildrenPanel.tsx, rendered only for a type whose allows_children is on. On the frontend, resources/js/hooks/useIssueHierarchy.ts turns the flat, paginated issues array into a rendered tree by grouping on parent_id — this is a page-local hierarchy: a child whose parent didn't happen to land on the same page of results renders as a top-level row instead, there's no cross-page tree. Collapsed-row state persists to localStorage per project, the same pattern resources/js/hooks/useTableResizing.ts already uses for column widths.

Issue creation itself was redesigned around this feature: the old NewIssueModal is gone, replaced by resources/js/Components/Molecules/QuickAddIssueRow/QuickAddIssueRow.tsx, an inline row at the top of IssueTable (and, for a type with allows_children, a second "Add sub-issue" row right under it). It renders as a real table row: the title and the issue type are editable, while the remaining cells preview what the issue will be created with — the id it will get (IssueService::peekNextIssueId(), a hint only, since a concurrent create wins the real id), the selected type's initial workflow status, Medium priority, and unassigned. The top-level row offers only is_top_level types; a nested one offers only the parent type's allowed children. Submitting posts to issues.store with the title, the chosen issue_type_id (falling back to IssueTypeService::defaultIssueType() when none is sent) and parent_id for a sub-issue — open the issue afterward to fill in the rest, or apply an IssueTypeTemplate (name, description, default_priority, default_labels, managed from Settings → Issue Types → Manage templates). A template applies without being asked for: with no template_id on the request, IssueTypeService::defaultTemplateFor() supplies the type's own, which is the only way quick-add — it sends nothing but a title — would ever see one. An explicit template_id overrides it, and anything the request actually carries wins over the template. Retyping an issue that nobody has written up yet adopts the new type's template too; an issue with a description keeps it. Template labels are narrowed through LabelService::filterToExisting() first, since they arrive from the server and so bypass the request's labels.* existence rule. resources/js/utils/quickAddIssueEvent.ts is a tiny window event bus so MainLayout's global "New issue" button/keyboard shortcuts can ask whichever IssueTable happens to be mounted to reveal and focus its quick-add input, without prop-drilling a ref across the layout boundary — it's a no-op on a page (Board/Calendar/Activity) that doesn't render an IssueTable at all.