spatie/laravel-harvest-sdk
A Laravel-friendly SDK for the Harvest.com API, with config publishing, credential setup, and container or facade access. It covers users and more, though not the full API—PRs welcome for missing features.
Four commands and three env vars.
composer require spatie/laravel-harvest-sdk
php artisan vendor:publish --tag="laravel-harvest-sdk-config"
php artisan config:clear
HARVEST_ACCOUNT_ID=1234567
HARVEST_ACCESS_TOKEN=your-personal-access-token
HARVEST_USER_AGENT='YourApp ([email protected])'
The user agent is functional, not cosmetic — Harvest uses it to identify your integration and to contact you if you misbehave. Put a real app name and a mailbox someone reads.
First move: prove auth works before you write a single feature.
use Spatie\Harvest\Facades\Harvest;
$me = Harvest::users()->me()->dto(); // Spatie\Harvest\Data\UserData
If that throws, the cause is almost always one of four things, in this order: token belongs to a different account than HARVEST_ACCOUNT_ID; user agent missing or malformed; stale cached config; token revoked in Harvest's UI. php artisan config:clear fixes a surprising number of "it worked in tinker" reports.
Where to read first — in this order:
src/Harvest.php plus the resource classes it exposes. This is the real surface area. The README is explicitly non-exhaustive, and the package README is the only documentation you get.src/Data/*. The DTOs are your contract. A field absent from a DTO is a field you don't have, regardless of what Harvest's API docs claim.config/laravel-harvest-sdk.php after publishing — check whether the base URL is overridable, because that determines how you'll fake it in tests.CHANGELOG / releases. Small but load-bearing: is_running on time entries landed in 1.0.1, Laravel 13 support in 2.1.0. If you're building a "who's tracking right now" widget, you need 1.0.1+.Ship this first: a read-only sync of projects and a bounded window of time entries into local tables. It exercises the three things that break in every Harvest integration — authentication, DTO hydration, and pagination — in a single afternoon, and gives you a fixture set you'll reuse in every later test.
Never let Spatie\Harvest\Facades\Harvest leak past one class. The package is partial by design and has essentially no downstream usage to shake out edge cases. Wrap it:
interface HarvestGateway
{
/** @return \Spatie\Harvest\Data\ProjectData[] */
public function projects(): array;
public function timeEntries(CarbonInterface $from, ?CarbonInterface $to = null): iterable;
}
final class SdkHarvestGateway implements HarvestGateway
{
public function projects(): array
{
return Harvest::projects()->all()->dto();
}
public function timeEntries(CarbonInterface $from, ?CarbonInterface $to = null): iterable
{
return Harvest::timeEntries()
->all(from: $from->toDateString(), to: $to?->toDateString())
->collect();
}
}
The payoff arrives the day you need invoices, expenses, tasks, or timer writes. Those aren't implemented. Add HttpHarvestGateway behind the same interface using Http::withToken(...)->withHeaders([...])->get('https://api.harvestapp.com/v2/...') and map into the same DTOs. Callers don't change, and you haven't forked the package. This is the highest-leverage structural decision available to you here.
Facade for glue, container for tests. app(Harvest::class) swaps cleanly in a service provider or a test; the facade is nicer in tinker and one-off artisan commands. Pick one per class and don't mix.
Sync into your own tables — never query Harvest on a page load. Harvest's documented limit is 100 requests per 15 seconds per account, shared across every token you hold. A dashboard fanning out per request will 429 in front of a client.
public function handle(HarvestGateway $harvest): void
{
$from = now()->subDays(3)->startOfDay(); // deliberate overlap
$to = now()->endOfDay();
foreach ($harvest->timeEntries($from, $to) as $dto) {
HarvestTimeEntry::updateOrCreate(
['harvest_id' => $dto->id],
[/* mapped fields */]
);
}
}
The overlapping window is the pattern that matters: a few days of lookback rather than "since last successful sync". It catches retroactive edits and deletions without needing webhooks, which this package doesn't document. updateOrCreate on the Harvest ID keeps it idempotent, so a retried job is free.
Handle pagination by streaming, not collecting. Time entries are the paginated resource, and the paginator yields pages:
$paginator = Harvest::timeEntries()->all(from: '2024-01-01', to: '2024-03-31');
foreach ($paginator as $page) {
foreach ($page as $entry) { // array<TimeEntryData>
// bounded memory
}
}
$all = $paginator->collect(); // only for short ranges / tinker
For backfills, chunk by month and dispatch one job per chunk. You get retry granularity and rate-limit backoff for free, and a failed March doesn't poison January.
Cache the slow resources, never the fast one. Users and projects move on the order of weeks; time entries are the volatile data and the entire reason you synced.
$projects = Cache::remember('harvest.projects', now()->addHour(), fn () => Harvest::projects()->all()->dto());
Testing: fake the HTTP layer, not the facade. Faking the facade proves nothing about DTO hydration, which is exactly where Harvest drift will bite you.
Http::preventStrayRequests();
Http::fake([
'api.harvestapp.com/v2/users/me' => Http::response($this->fixture('users.me'), 200),
]);
$user = app(Harvest::class)->users()->me()->dto();
$this->assertInstanceOf(UserData::class, $user);
$this->assertSame(1234567, $user->id);
Capture a handful of real payloads once — one user, one project, one page of time entries — and assert the mapping. preventStrayRequests() guarantees CI can never accidentally hit production Harvest.
Grep the vendored src/ before committing to a roadmap item. Users, projects, and time-entry reads are documented. Invoices, estimates, expenses, tasks, roles, timer writes, and webhooks are not. A missing method is a ten-minute fallback via Http, not a blocker — but it is a scope change, and you should know that before you say "we'll just pull the invoices across".
->dto() gives you arrays, not collections. array<UserData>, and for paginated resources an array of arrays. Wrap with collect() at the boundary where you actually need filter/groupBy. Don't assume fluent collection chaining survives the call — check the return type each time, because the code is the documentation.
from is mandatory on time entries. There is no "give me everything" mode; that's a Harvest API constraint, not a package omission. Unbounded backfills are also catastrophically slow and will trip the rate limit.
Date semantics are the silent killer. Harvest records spent_date per entry, but reporting and filtering interact with user and account timezones. If you're reconciling tracked hours against invoices or payroll, spot-check one known day against the Harvest UI before you trust the aggregate. An off-by-one here is invisible for weeks and expensive once it's in a client's report.
Harvest has no "deleted" feed in this SDK. Deletions in Harvest won't propagate through an upsert-only sync. Either do periodic full-window reconciliation (fetch the range and soft-delete local rows with no Harvest counterpart), or accept the drift explicitly. Don't discover this during a billing dispute.
Token lifecycle is out of scope. The env-var flow assumes one long-lived personal access token for one account. Multi-tenant OAuth — each customer authorising their own Harvest account — means you own refresh-token storage and rotation yourself; nothing in the package helps. Design the HarvestGateway binding to be per-tenant from the start if that's on the horizon, because retrofitting it is painful.
Debug by logging at the HTTP layer. When a response looks empty or a 403 appears, add Http::globalRequestMiddleware(fn ($request) => logger()->debug($request->url())) or listen to Illuminate\Http\Client\Events\*. You'll see the exact URL the SDK built, which is the fastest way to distinguish "wrong account" from "endpoint not implemented".
Rate-limit defensively in jobs. ->retry(5, fn (int $attempt) => $attempt * 5000) plus RateLimited/429 handling on your queued syncs is cheaper than discovering the limit under load. Harvest counts per account, so parallel workers compete with each other.
Extend by PR, not by fork. The maintainers state plainly that PRs for missing endpoints are welcome. A small resource class plus a DTO matching the project's conventions is a better investment than a private fork you have to rebase — and it's the only path to the package surviving a Laravel major bump without you doing the work.
How can I help you explore Laravel packages today?