dniccum / linear-sdk
A Laravel SDK for creating Linear issues from Eloquent models, with an optional branded configuration page.
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/bus: ^12.0||^13.0
- illuminate/cache: ^12.0||^13.0
- illuminate/contracts: ^12.0||^13.0
- illuminate/database: ^12.0||^13.0
- illuminate/http: ^12.0||^13.0
- illuminate/queue: ^12.0||^13.0
- illuminate/routing: ^12.0||^13.0
- illuminate/support: ^12.0||^13.0
- illuminate/validation: ^12.0||^13.0
- illuminate/view: ^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/boost: ^2.10
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0||^11.0
- orchestra/workbench: ^10.0||^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-09 17:06:15 UTC
README
Linear SDK for Laravel
Note
This is an unofficial, community-built package. It is not affiliated with, endorsed by, or sponsored by Linear. "Linear" and the Linear logo are trademarks of Linear. This package simply talks to Linear's public API.
Create Linear issues from your Eloquent models, the way Laravel Spark handles billing: add a trait to a model, let your users connect their Linear workspace on a ready-made configuration page, and issues are created for you from the model's lifecycle events.
- Model traits that add Linear attributes (
linear_issue_url,linear_issue_identifier,linear_sync_status) and tie issue creation tocreated,updatedanddeleted. - Optional configuration page with your own branding: connect (OAuth 2.0 with PKCE, or a personal API key), pick the team, project, status, labels, priority and assignee, and choose automatic or manual sending. Don't want it? Turn it off and build your own UI on the headless Actions.
- Safe by design: issues and comments are created from queued jobs with retries, are idempotent (a retry never creates a duplicate), and a failure never breaks the save of your own model.
- Fully typed (PHPStan level 9, strict TypeScript) and tested to 100% coverage.
Requirements
- PHP 8.3 or 8.4
- Laravel 12 or 13
- A Linear workspace, plus either a Linear OAuth application or personal API keys
Installation
composer require dniccum/linear-sdk
Publish and run the migrations, then (optionally) the config file and the page's compiled assets:
php artisan vendor:publish --tag=linear-migrations
php artisan migrate
php artisan vendor:publish --tag=linear-config
php artisan vendor:publish --tag=linear-assets # required for the configuration page
| Tag | Publishes |
|---|---|
linear-config |
config/linear.php |
linear-migrations |
the four tables (set table_prefix / key_type in the config before migrating) |
linear-views |
the Blade views, so you can restyle the page |
linear-assets |
the compiled JS/CSS to public/vendor/linear |
Keep the front-end assets up to date
The configuration page's JavaScript and CSS are published into your public directory, so a copy from an older release stays there until you publish again. The page reads its asset manifest from the installed package but serves the files from public/vendor/linear, so after an upgrade with stale published files the page would link to bundles that no longer exist and fail to load. Re-publish them every time Composer updates the package by adding the command to the post-update-cmd scripts in your application's composer.json:
"scripts": { "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force", "@php artisan vendor:publish --tag=linear-assets --ansi --force" ] }
(laravel-assets is already there in a default Laravel application; add the linear-assets line beneath it.) The --force flag overwrites the previously published files, and the compiled files use hashed names, so browsers pick up the new version straight away. If you deploy with composer install, run the same command as a deploy step, or add it to post-install-cmd too.
If you do not use the built-in page (the ui route group is off, or you build your own UI), you can skip this.
Make sure a queue worker is running; issues and comments are sent from queued jobs.
Configuration
LINEAR_AUTH_MODE=oauth # or api_key LINEAR_CLIENT_ID=... LINEAR_CLIENT_SECRET=... # LINEAR_REDIRECT_URI= # defaults to route('linear.callback')
For OAuth, create an application at Linear → Settings → API → OAuth applications and register https://your-app.test/linear/callback as its callback URL. The requested scopes are read, issues:create and comments:create; the grant is refused if Linear returns fewer.
In api_key mode each owner pastes a personal API key (Linear → Settings → API). It is validated against Linear when saved, stored encrypted, and never expires.
Every option is documented in config/linear.php.
Quick start
1. Mark the owner
The owner is whoever connects a Linear workspace: usually a User or a Team.
use Dniccum\Linear\Concerns\HasLinearConnection; class User extends Authenticatable { use HasLinearConnection; }
$user->hasLinearConnection(); // bool $user->linearConnection; // LinearConnection|null $user->linearDestination; // LinearDestination|null (team, project, state, labels, ...) $user->linearClient(); // talk to Linear as this owner $user->disconnectLinear();
2. Mark the models that become issues
use Dniccum\Linear\Concerns\CreatesLinearIssues; class SupportRequest extends Model { use CreatesLinearIssues; protected $appends = ['linear_issue_url', 'linear_issue_identifier', 'linear_sync_status']; public function user(): BelongsTo { /* ... */ } }
That's it. When a SupportRequest is created and its owner has an active connection with a destination in automatic mode, an issue is filed in Linear. The model gains:
$request->linear_issue_url; // "https://linear.app/acme/issue/SUP-42/..." $request->linear_issue_identifier; // "SUP-42" $request->linear_sync_status; // LinearSyncStatus::Pending|Synced|Failed|null $request->linearIssueLink; // the underlying LinearIssueLink
3. Send manually, retry, comment
$request->sendToLinear(); // file it now (manual mode) $request->sendToLinear(['title' => 'Custom title']); $request->retryLinear(); // retry a failed sync $request->commentOnLinear('Customer replied.'); // comment on its issue
4. Open the page
Visit /linear while signed in. Done.
Tying issues to model lifecycle events
Override linearEvents() to choose which events act on Linear (default: ['created']):
public function linearEvents(): array { return ['created', 'updated', 'deleted']; }
| Event | Not yet linked | Already linked |
|---|---|---|
created / updated |
files the issue (when the owner is in automatic mode) | updated: posts a comment listing changes if on_update is comment; otherwise nothing |
deleted |
nothing | posts "was deleted" if on_delete is comment; otherwise nothing |
Set on_update / on_delete in config/linear.php.
Customising the issue
Override any hook on your model (or implement Dniccum\Linear\Contracts\ComposesLinearIssue to take full control):
public function linearOwner(): ?Model // who owns the connection (default: $this->user) public function linearTitle(): string // issue title public function linearDescription(): string // issue description (Markdown) public function linearComment(string $event, array $context = []): string public function linearDestinationOverride(): ?LinearDestination // use a different team/project for this model
Events
Listen for what happened:
use Dniccum\Linear\Events\{LinearIssueCreated, LinearIssueFailed, LinearCommentDelivered}; Event::listen(LinearIssueCreated::class, fn ($e) => logger($e->link->linear_issue_url));
The configuration page
GET /linear renders a standalone, branded page: connection card, destination form (team, project, status, labels, priority, assignee, automatic / manual), and a list of recent failures with a retry button.
Branding
// config/linear.php 'brand' => [ 'name' => 'Acme Support', 'logo' => '/images/logo.svg', // URL or null 'color' => '#0F766E', // accent colour ],
Run php artisan vendor:publish --tag=linear-views to change the markup.
Authorization and the owner
By default the signed-in user ($request->user()) is the owner and the routes use the web and auth middleware. Customise from a service provider:
use Dniccum\Linear\Facades\Linear; Linear::resolveOwnerUsing(fn (Request $request) => $request->user()->currentTeam); Linear::authorizeUsing(fn (Request $request, Model $owner) => $request->user()->can('manage-integrations', $owner));
Change the URL or middleware with path and middleware in the config.
Back link
A "Back" link at the top of the page lets users leave the configuration interface. The text and the destination are both configurable, and the destination defaults to /dashboard:
// config/linear.php (or LINEAR_BACK_LABEL / LINEAR_BACK_URL in .env) 'back' => [ 'enabled' => true, // false hides the link 'label' => 'Back', // text, or a translation key such as 'linear.back' 'url' => '/dashboard', // a route name ('dashboard'), a path or a full URL ],
When the destination depends on who is looking (a team, a tenant), choose it at runtime from a service provider's boot() method. The callback receives the owner and returns a route name, a path or a URL, or null to hide the link; it takes precedence over back.url:
use Dniccum\Linear\Facades\Linear; Linear::backUsing(fn (Model $owner) => route('teams.show', $owner));
The link is part of the settings payload (back) and of Linear::settingsFor($owner)->back, so custom UIs can render it too. If you embed <x-linear::settings /> inside your own layout, set back.enabled to false.
Signed-out users
If a user's session expires while they are using the page (or they open it signed out), they are sent to your login page instead of seeing a raw error. The page's requests that come back 401 or 419 redirect the browser, and package routes reached without a signed-in user redirect there too. The destination defaults to your login route and is configurable:
// config/linear.php (or LINEAR_LOGIN_ROUTE in .env) 'login_route' => 'login', // a route name // 'login_route' => '/sign-in', // a path // 'login_route' => 'https://sso.example.com/login', // a full URL // 'login_route' => null, // disable: requests are refused with a 403 instead
Embedding or disabling it
Embed the page inside your own layout, with the route group turned off:
<x-linear::settings /> <x-linear::assets />
// config/linear.php 'routes' => ['ui' => false, 'oauth' => true, 'api' => true],
The three route groups (ui, oauth, api) are independent. To register no routes at all, call Linear::ignoreRoutes() from a service provider's register() method.
Building your own UI
Everything the page does is available as headless, typed Actions, and as a documented HTTP contract (docs/http-contract.md). A reference for the data they return, with a worked custom page, is in docs/custom-ui.md.
use Dniccum\Linear\Actions\{BuildConnectUrl, HandleOAuthCallback, SaveApiKey, DisconnectLinear, ListTeams, ListTeamOptions, SaveDestination, DeleteDestination, RetryFailedSync}; app(BuildConnectUrl::class)->execute($request->session()); // OAuth URL to redirect to app(SaveApiKey::class)->execute($owner, $request->string('api_key')); $teams = app(ListTeams::class)->execute($owner); $options = app(ListTeamOptions::class)->execute($owner, $teamId); app(DisconnectLinear::class)->execute($owner); $settings = Linear::settingsFor($owner); // typed DTO for rendering your own page
Disable the package routes (routes.* => false or Linear::ignoreRoutes()) and wire the Actions into your own controllers, Inertia pages or Livewire components.
Team, project, status and member visuals
The data the Actions return (and $owner->linearClient() / Linear::client($owner)) carries what Linear itself shows next to each choice, so a custom UI can look like Linear. All of it is optional (null when Linear sends nothing):
| DTO | Visual properties |
|---|---|
Team |
color (hex), icon (an emoji or a Linear icon name, never a URL), iconIsEmoji() |
Project |
color, icon, iconIsEmoji() |
WorkflowState |
color, type (triage, backlog, unstarted, started, completed, canceled, duplicate), kind() returning a LinearStateType |
Member |
avatarUrl, initials, avatarBackgroundColor, displayInitials() |
Priorities have no API data, so Dniccum\Linear\Enums\LinearPriority lists them (NoPriority=0, Urgent=1, High=2, Medium=3, Low=4) with label() and options(). toArray() on each DTO matches the HTTP contract exactly.
foreach (app(ListTeams::class)->execute($owner) as $team) { echo $team->iconIsEmoji() ? $team->icon : strtoupper(substr($team->key, 0, 2)); // tile text echo $team->color ?? '#5e6ad2'; // tile colour }
docs/custom-ui.md documents every field (type, nullability, meaning), how icons, colours and avatars should be rendered, what each status type means, and includes a complete custom controller and Blade view that works without the bundled UI.
Important
Avatars are loaded by the browser directly from the avatarUrl Linear returns (the package never proxies them). If your app sends a strict Content-Security-Policy, its img-src directive must allow the image hosts of your members' avatars, in the bundled page as well as in your own UI. When an image is blocked or fails to load, the bundled page falls back to the member's initials on their avatar colour, so nothing breaks; it simply looks plainer.
Testing your application
Linear::fake() swaps Linear for an in-memory double, so no HTTP request is made:
use Dniccum\Linear\Data\IssuePayload; use Dniccum\Linear\Exceptions\LinearApiException; use Dniccum\Linear\Facades\Linear; it('files a Linear issue for a new support request', function () { $linear = Linear::fake(); $request = SupportRequest::factory()->create(); $linear->assertIssueCreated(fn (IssuePayload $payload) => $payload->title === $request->subject); }); Linear::fake()->failWith('createIssue', new LinearApiException('Linear is down', LinearApiException::TRANSIENT), times: 2);
Available helpers: withTeams(), withTeamOptions(), withViewer(), withTokens(), failWith(), assertIssueCreated(), assertIssueCreatedCount(), assertNoIssueCreated(), assertCommentPosted(), assertNoCommentPosted(), assertNothingSent().
How it behaves
- Idempotent. Each issue and comment gets a client-generated UUID. If a request times out after Linear created the issue, the retry finds and adopts it instead of creating a duplicate.
- Resilient. Jobs retry 5 times with backoff (30s, 2m, 10m, 30m). Rate limits and outages retry; permanent rejections fail fast and show up on the configuration page with a retry button.
- Safe. Lifecycle hooks swallow and
report()their errors, so Linear can never lose or break a save of your own model. - Self-healing auth. OAuth tokens refresh under a cache lock; connections whose authorization is revoked are marked needs reconnect and shown that way in the UI.
Frontend development
The page is TypeScript + Alpine compiled with Vite. The compiled assets are committed, so using the package never requires Node. To work on the page itself, see docs/frontend.md:
npm ci LINEAR_WORKBENCH_FAKE=true LINEAR_AUTH_MODE=api_key composer serve # demo app, no real Linear needed LINEAR_VITE_DEV_URL=http://localhost:5173 npm run dev # HMR npm run build # rebuild and commit public/build
Testing the package
composer check # pint, phpstan (level 9), pest with 100% coverage npm run typecheck && npm run lint && npm run test:coverage
CI runs the PHP suite on PHP 8.3/8.4 × Laravel 12/13 × lowest/stable on Linux and Windows, plus PHPStan, Pint and the frontend checks.
Changelog
See CHANGELOG.
Contributing
See CONTRIBUTING.
Credits
- Doug Niccum
- Inspired by the billing experience of Laravel Spark.
License
The MIT License (MIT). See LICENSE.
