axyr/laravel-productive
A Laravel package for integrating Productive.io into your app. It provides a configured API client and convenient Laravel bindings for managing projects, tasks, time tracking, invoices, and other agency data through Productive's REST API.
This is a generated, typed SDK over the whole Productive API — 668 operations across 139 resources — so the first thing to understand is that you rarely write raw HTTP. You call a typed resource, pass a typed input, and get readonly models back.
composer require axyr/laravel-productive
php artisan vendor:publish --tag="productive-config"
.env. You need an organization id and a token; the package supports multiple of each, selected per call.PRODUCTIVE_TOKEN=your-api-token
PRODUCTIVE_ORGANIZATION=123456
php artisan productive:ping
This is the fastest way to distinguish "bad token / wrong org" from "wrong endpoint" — both otherwise surface as typed exceptions from the connector.
use Axyr\LaravelProductive\Facades\Productive;
$projects = Productive::projects()->get();
$project = Productive::projects()->first(); // or ->find($id)
$company = $project->company; // relationship accessor
docs/reference.md — the endpoint catalogue. Every resource, method and input class is listed there because the code is generated; your IDE will autocomplete, but this is the map of what exists.docs/api-differences.md — read this before you trust the spec. It lists the 31 corrected types, 73 undocumented attributes and 10 undocumented relationships the package already compensates for. It tells you where the API is quirky so you don't conclude the SDK is broken.Productive::fake() docs, if you plan to test anything.Wrap the SDK in your own service layer, don't sprinkle it through controllers. Generated code has stable but spec-shaped names, and the weekly spec-drift workflow means signatures can shift on a minor release. One application-level class per domain (Projects, Deals, TimeEntries) gives you a single place to absorb that churn.
Use inputs, not arrays. Create/update payloads are typed input objects, and they deliberately distinguish omitted from explicit null:
UpdateProjectInput::make()
->name('Renamed') // sent
->description(null); // sent as null, clearing the field
// budget omitted entirely — untouched
That distinction is the whole reason to prefer inputs. Hand-rolled arrays cannot express "don't touch this field."
Query with filter groups rather than post-filtering. Nested whereAny / whereAll support means you can push compound conditions to the API:
Productive::timeEntries()
->filter(fn ($f) => $f
->where('date', '>=', $from)
->whereAny(fn ($g) => $g
->where('billable', true)
->where('invoice_id', null)
)
)
->sort('-date')
->get();
Choose the right terminal method.
get() — a single page, when you know the result set is small.paginate() — for UI tables; uses cursor pagination and falls back to page numbers where the endpoint doesn't support cursors.lazy() — for anything unbounded. This is the one to use for sync jobs and exports.all() — convenient, dangerous. It materialises everything; only safe behind a filter that bounds the set.first() / count() — cheap existence checks and dashboard counters.Use ext=bulk and includes to kill N+1s. The request builders expose ext=bulk, and relationships can be side-loaded via includes. For a list screen showing projects with companies and people, one request with includes beats 1 + 2N.
Be explicit about organization and token per call. Both are selectable per request, which is what makes multi-tenant agencies and Octane workers workable:
Productive::organization($tenant->productive_org_id)
->token($tenant->productive_token)
->projects()
->get();
Do this deliberately rather than mutating global state — the config default is fine for single-tenant apps, not for queues processing jobs for different tenants.
Test against the wire, not the model. Productive::fake() records requests as they actually go over the wire and seeds responses by operation or path, so your tests assert on what the SDK would have sent:
Productive::fake(['projects.*' => [/* seeded payload */]]);
// ... exercise your service ...
Productive::assertSentToOrganization($orgId, 'projects.create');
Combine this with the shipped factories (built from Productive's own examples) to get realistic payloads instead of hand-written fixtures.
Respect the retry/throttle policy; don't add your own. 429s are retried for every method, server errors and dropped connections only for reads. Client-side throttling is cache-backed and per token, with a reports-specific limit. Layering a naive Http::retry() on top double-counts and can trip the API harder.
Writes are largely unverified against the live API. This is the single most important caveat in the release. Reads are validated against recorded responses; of the writes, only 14 creates have ever been sent to production. Updates, deletes and actions are checked against the spec plus the fake. Treat every write path as needing your own integration test against a sandbox before it goes near a customer's data — particularly action endpoints, which are the least exercised.
Required fields don't come from the spec. The generated inputs require fields Productive actually demands, which the OpenAPI spec fails to mark. If you're debugging a 422 that "shouldn't happen" because the field looked optional in the docs, read docs/api-differences.md — it's documented, and the SDK is right.
Relationship accessors throw on type mismatch. That's intentional: a nullable accessor returning the wrong model type is worse than an exception. But it means an unexpected API change can hard-fail a page load. Catch at the boundary of your own service layer, don't let Model exceptions bubble into Blade.
Models are readonly. You cannot mutate and save() — every change is an explicit update call with an input. If you're coming from Eloquent, this is the mental shift that costs the first afternoon.
A repeated next link silently stops pagination. The SDK guards against looping by halting when the API returns the same next link twice. Good for safety, but it means a truncated lazy() run can look like a complete one. If completeness matters (billing exports, reconciliation), assert an expected count independently.
Throttling state lives in the cache. With multiple app servers or Octane workers, the cache store must be shared (Redis/memcached). On the default array or file driver per-container you get independent throttles — and a reports-heavy job can collectively blow the limit while each process believes it's compliant.
Retries are asymmetric on purpose. Reads retry on 5xx and dropped connections; writes do not. So a network blip during a create fails fast rather than risking a duplicate record. If you need write resilience, implement your own idempotency check (search-then-create), don't widen the retry policy.
Redirects are never followed. The connector is configured not to. A base-URL or endpoint change manifests as a redirect you'll see in the typed exception rather than transparently working — check the exception's status before assuming an auth problem.
Watch the weekly spec-drift PRs. A workflow opens a pull request whenever the generated SDK diverges from the spec. Those PRs are real surface changes — pin the package version and review generated diffs before bumping, especially if you rely on exact input signatures.
No webhook receiver yet. Inbound events are not handled at all. If you need reactive behaviour, you're polling (lazy() with a updated_at filter) or wiring your own endpoint — don't assume the package will grow this transparently.
Maintenance commands are guarded for a reason. composer record is GET-only and restricted to a single organization so you don't accidentally record or replay against real customer data. record:types and record:fixtures include a leak check — treat any failure there as a hard stop rather than a warning, since these artifacts become test fixtures.
Report grouping is a distinct path. Report endpoints have their own grouping syntax and their own throttle limit. If a report is returning rows instead of aggregates, you've likely dropped the grouping builder rather than hit a bug.
How can I help you explore Laravel packages today?