torann/laravel-asana
Laravel package that provides a simple, fluent wrapper around the Asana API. Register the service provider, configure your access token, and manage projects, tasks, workspaces, and users directly from your Laravel app with an expressive, Eloquent-like interface.
Pin the version, then read the source. This is a pre-1.0 package with a small maintainer base and no visible CI matrix. Install it exactly:
composer require torann/laravel-asana:0.4.9
Avoid ^0.4. Patch releases here have historically been Composer constraint bumps (0.4.9 is a Laravel 13.x compatibility bump, nothing more), and 0.4.x can move constraints under you. Commit the lock file, and open the package's own composer.json to check PHP/Laravel constraints against your app before you install — the release note is a claim, not a test result.
Where to look first. The wrapper is genuinely small. Spend ten minutes reading, in this order:
src/AsanaServiceProvider.php — how the client is registered and whether it's a singleton or a per-resolution binding.getFacadeAccessor() — what it actually resolves from the container.That's your real documentation. Everything below that line is the official asana/asana PHP SDK's resource classes and response shapes, which are not this package's problem to document.
Publish the config and put the token in .env.
php artisan vendor:publish --provider="Torann\Asana\AsanaServiceProvider"
Generate a Personal Access Token from Asana → Developer console → Personal access tokens. Not an OAuth app — the config shape is built around a single static token (see Gotchas). Use a dedicated token tied to a bot/service user so revocation doesn't break a human's account, and point it at a sandbox workspace first.
ASANA_ACCESS_TOKEN=1/1234567890abcdef...
Verify resolution before writing any domain code. Two things must work in tinker:
\Asana::users->me(); // facade path
app(\Asana\Client::class)->users->me(); // container path — build on this
If users->me() returns your identity, the plumbing is done. Note which SDK version resolved while you're there; that's the actual API surface you'll be programming against.
First use case: event-driven task creation. The highest-value, lowest-risk slice is "something happened in our app → push a unit of work into Asana." One listener, one workspace, one project, GIDs pulled from config:
public function handle(LeadCreated $event): void
{
app(\Asana\Client::class)->tasks->create([
'workspace' => config('services.asana.workspace_gid'),
'projects' => [config('services.asana.projects.intake')],
'name' => "New lead: {$event->lead->company}",
'notes' => $event->lead->summary,
]);
}
Treat workspace, project, and assignee GIDs as configuration constants gathered once from the UI — not as runtime lookups scattered through your code.
When it breaks, check config/token first, then the SDK. The wrapper is thin enough that nearly every failure is one of two things: (a) token or config not loading, or (b) the underlying SDK's request/response contract. Debug in that order.
This is the pattern that determines whether this integration is cheap or expensive in six months. Never let \Asana\Client or SDK response objects reach a controller, model, or job. Define an app-owned contract and one adapter that implements it:
interface AsanaGateway
{
public function createTask(NewTask $task): AsanaTaskRef;
public function completeTask(AsanaTaskRef $ref): void;
public function projectTasks(string $projectGid): iterable;
}
final class AsanaSdkGateway implements AsanaGateway
{
public function __construct(private \Asana\Client $client) {}
public function createTask(NewTask $task): AsanaTaskRef
{
$result = $this->client->tasks->create([
'workspace' => config('services.asana.workspace_gid'),
'projects' => [$task->projectGid],
'name' => $task->name,
'notes' => $task->notes,
]);
return new AsanaTaskRef($result->gid);
}
}
// AppServiceProvider::register()
$this->app->bind(AsanaGateway::class, AsanaSdkGateway::class);
The payoff is threefold: the package becomes replaceable (swap one class for raw Guzzle or a newer SDK and nothing else moves), the integration is trivially fakeable in tests, and your domain never learns Asana's vocabulary.
Asana is a third party with real latency and rate limits. Writes belong in jobs:
final class SyncTaskToAsana implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public int $tries = 5;
public array $backoff = [10, 30, 60, 300];
public function handle(AsanaGateway $asana): void
{
$asana->createTask($this->task);
}
public function failed(\Throwable $e): void
{
// log with correlation ID, alert, and record the failure
}
}
None of tries, backoff, or failed() come from this package — they're yours. Without them, a transient 429 or 500 becomes silently lost work that nobody notices for a week.
Retry plus a non-idempotent POST equals duplicate tasks in the client's project. Two workable approaches:
asana_links row (local_id, local_type, asana_gid, synced_at) created in the same transaction that dispatches the job. On retry, check the link before creating.notes or a custom field, e.g. local_id:12345. Slightly racy, but cheap and fine for a pilot.The wrapper hides the retry mechanics, which makes it easy to forget this step entirely. Don't.
For mirroring Asana into your app (status dashboards, client portals), treat Asana as the source of truth and your DB as a cache:
foreach ($client->projects->getTasks($gid, ['opt_fields' => 'name,completed,assignee.name,due_on']) as $task) {
ProjectTask::updateOrCreate(
['asana_gid' => $task->gid],
['name' => $task->name, 'completed' => $task->completed]
);
}
Two rules. Always pass opt_fields — default payloads are fat and slow, and a sync that "feels fine" against 20 tasks will crawl against 2,000. And drive reads from a scheduled command or queued job, never inline in a request a user is waiting on.
Asana's model (workspace → project → section → task, plus custom fields) rarely matches a product's. Put the translation in a dedicated mapper class, not inline in the gateway. Write down what you deliberately drop — subtasks, dependencies, custom field types you don't support — so the boundary is documented and future changes stay contained.
You will almost certainly have more than one destination. Namespace the GIDs in config/services.php, resolve them via env, and let callers pass a key:
'asana' => [
'workspace_gid' => env('ASANA_WORKSPACE_GID'),
'projects' => [
'intake' => env('ASANA_PROJECT_INTAKE'),
'support' => env('ASANA_PROJECT_SUPPORT'),
],
],
Remember that config:cache freezes these in production — any branching on tenant or environment must happen above the config lookup, not inside it.
Because everything routes through AsanaGateway, tests just bind a fake:
$this->mock(AsanaGateway::class, function ($m) {
$m->shouldReceive('createTask')->once()->andReturn(new AsanaTaskRef('123'));
});
For the adapter itself, unit-test the mapping against recorded SDK response fixtures — never live HTTP. Keep one smoke test against the sandbox workspace, guarded behind a token check so it stays out of CI.
The facade is a debugging tool, not an architecture. \Asana::tasks->create(...) reads beautifully in tinker and rots in application code — static calls are hard to fake and impossible to swap. Resolve app(\Asana\Client::class) inside your adapter and leave the facade for the REPL.
Check whether the client is bound as a singleton. If it is, one instance is reused per request. That's usually fine, but it forecloses per-tenant tokens later. Read the provider's register() method and don't assume — the difference matters if you ever move beyond a single workspace.
Laravel 13 compatibility is a claim, not a guarantee. 0.4.9 is a constraint bump with no visible CI behind it. Smoke-test your exact Laravel/PHP combination on a throwaway branch. One users->me() call tells you more than the release note does.
Upgrade deliberately. Pin to 0.4.9, read the diff before every bump, and don't let a composer update drag this along with everything else.
Auth scope is PAT-only, realistically. The config is built around one access token. Multi-tenant OAuth — where your customer connects their Asana, with per-tenant refresh and revocation — is entirely out of scope and you own the whole layer. Draw that line early with whoever is asking for it.
The package handles no rate limiting, retries, or 429 backoff. Asana returns 429 with a Retry-After header. Respect it in your job backoff, log it, and track 429 rate as a metric — a rising rate means slow the scheduler down, not add retries.
Webhooks are not covered. Real-time Asana → app sync means you implement registration, the handshake, and X-Hook-Signature HMAC verification yourself. Start with scheduled polling; add webhooks only when latency genuinely demands it.
Learn the SDK's pagination semantics. Iteration typically yields pages transparently, but breaking out early or slicing results can silently truncate. Log a count at the end of every sync and alert on zero or an unexpected spike — silent truncation is the failure mode that erodes trust in the integration.
Handle Asana's error envelope by class, not uniformly. Errors arrive as an HTTP status plus errors[].message, and the SDK throws on non-2xx. Split them: 4xx is your bug (bad GID, missing required field, permission) — don't retry, alert. 429/5xx is theirs — retry with backoff. Treating both the same buries real mapping errors in retry noise until they're invisible.
config:cache masks a missing token locally. After caching, env() outside config files returns null. If auth works locally but breaks in staging, run config:clear and confirm ASANA_ACCESS_TOKEN exists in the environment the cache was built in.
Log correlation IDs. Capture whatever request ID Asana returns alongside your own job/correlation ID. Three days later when a sync goes sideways, that thread is the difference between a ten-minute fix and an afternoon of archaeology.
Watch for SDK classes leaking through the adapter. Any method that returns \Asana\Resource\Task or a raw response re-couples your app to the package. Map to small local DTOs (AsanaTaskRef, AsanaProjectSummary) right at the boundary — this is the difference between a one-day swap and a one-week migration if the package goes stale.
Keep a warm escape hatch. Stub a thin HttpAsanaClient (Guzzle, Bearer auth, same interface) on a branch. If the maintainer goes quiet or the SDK is missing a method you need, you finish the stub instead of starting it. BSD-2-Clause makes forking legal, but a fork is permanent maintenance; a 40-line fallback is not.
Don't reach for this if the trigger is trivial. If the requirement is literally "when a form submits, create a task," Asana's native rules or a Zapier/Make connector solve it with no code and no dependency. Use the package when the logic genuinely lives in your domain — enrichment, deduplication, entitlement checks, records that exist nowhere but your DB.
How can I help you explore Laravel packages today?