foundry-co / tableau-api
A framework-agnostic PHP client for the Tableau Server / Tableau Cloud REST API, built on Guzzle.
Requires
- php: ^8.4
- firebase/php-jwt: ^6.11.2|^7.0
- guzzlehttp/guzzle: ^7.8
Requires (Dev)
- illuminate/support: ^10.0|^11.0|^12.0
- orchestra/testbench: ^8.0|^9.0|^10.0
- pestphp/pest: ^3.8
- phpunit/phpunit: ^10.0|^11.0
Suggests
- illuminate/contracts: Required if you want to use the Laravel service provider, facade, and config integration.
- illuminate/support: Required if you want to use the Laravel service provider, facade, and config integration.
This package is auto-updated.
Last update: 2026-08-10 17:04:20 UTC
README
A modern, framework-agnostic PHP client for the Tableau Server / Tableau Cloud REST API.
- Framework agnostic. usable in any PHP 8.4+ project.
- Built on Guzzle. Uses
guzzlehttp/guzzledirectly for HTTP — the only hard dependency. - Optional Laravel integration. Ships a service provider, facade, and publishable config file for Laravel apps (requires
illuminate/support+illuminate/contractswhen used). - Workflow helpers. Common multi-call Tableau operations (publish-then-refresh, provision-a-user, clone-permissions, refresh-and-wait) are wrapped in single method calls.
Requirements
- PHP 8.4+
guzzlehttp/guzzle^7.8
Installation
composer require foundry-co/tableau-api
Quick start (framework agnostic)
use FoundryCo\TableauApi\TableauClient; $tableau = new TableauClient([ 'server_url' => 'https://tableau.example.com', 'site_content_url' => 'marketing', // '' for the Default site 'token_name' => 'my-personal-access-token-name', 'token_secret' => 'my-personal-access-token-secret', ]); $tableau->signIn(); foreach ($tableau->projects()->list() as $project) { echo $project['name'], PHP_EOL; } $tableau->signOut();
A Connected App is also supported, and preferred for server-to-server integrations:
$tableau = new TableauClient([ 'server_url' => 'https://tableau.example.com', 'connected_app_client_id' => 'client-id', 'connected_app_secret_id' => 'secret-id', 'connected_app_secret_value' => 'secret-value', 'connected_app_user' => 'service-account@example.com', 'connected_app_scopes' => ['tableau:content:read', 'tableau:permissions:update'], ]);
Username/password sign-in is also supported (Tableau is deprecating this for most deployments, but it's still available where enabled):
$tableau = new TableauClient([ 'server_url' => 'https://tableau.example.com', 'username' => 'alice', 'password' => 'secret', ]);
You can also build the client from an explicit TableauConfig value object, or inject your own GuzzleHttp\ClientInterface (useful for testing with a mock handler, or to add your own middleware/logging):
use FoundryCo\TableauApi\Config\TableauConfig; use FoundryCo\TableauApi\TableauClient; use GuzzleHttp\Client as GuzzleClient; $config = new TableauConfig( serverUrl: 'https://tableau.example.com', apiVersion: '3.24', siteContentUrl: 'marketing', tokenName: 'name', tokenSecret: 'secret', ); $tableau = new TableauClient($config, new GuzzleClient());
If you don't pass a Guzzle client, TableauClient builds one for you from the TableauConfig (base URL, timeout, SSL verification, user agent).
Obtaining your Tableau credentials
Server URL — the base address of your Tableau pod, with no trailing path. For Tableau Cloud, this is https://<pod>.online.tableau.com — the <pod> is visible in your browser's address bar after logging in (e.g. 10ay, us-east-1, prod-uk-a). For Tableau Server, it's whatever internal hostname your admin gave you, e.g. https://tableau.internal.example.com.
Site — the contentUrl segment identifying your site, visible in the browser URL as .../#/site/<this-part>/.... If you're on the server's Default site, this is an empty string.
Connected App (Direct Trust) — the recommended option for a server-to-server integration like this one, since it doesn't tie authentication to one person's account and doesn't expire from inactivity the way a PAT does:
- As a site or server admin, go to Settings → Connected Apps in Tableau.
- Click New Connected App → choose Direct Trust, give it a name, and save. Tableau generates a Client ID — copy it into
TABLEAU_CONNECTED_APP_CLIENT_ID. - On the same Connected App, click Generate new secret. Tableau shows a Secret ID and Secret Value exactly once — copy them into
TABLEAU_CONNECTED_APP_SECRET_IDandTABLEAU_CONNECTED_APP_SECRET_VALUE. - Connected Apps authenticate as a specific user (there's no separate "service account" identity) — set
TABLEAU_CONNECTED_APP_USERto the value described below, for the Tableau user whose permissions the integration should run under. Skip this if you'll supply the user dynamically at runtime instead (see Impersonating a dynamic user below). - Under the Connected App's Access settings, restrict which domains/projects it can reach if desired, and confirm it's Enabled.
- The client builds and signs the required JWT itself via
firebase/php-jwt(HS256,kid= Secret ID,sub= the user).
What value goes in connected_app_user / the sub claim — it must be the Tableau username exactly as Tableau has it stored for that user, not their display name:
- Tableau Cloud: this is the user's email address.
- Tableau Server: it's the login name from your identity provider — for local auth that's whatever username was set when the account was created; for AD/SAML it's typically
DOMAIN\usernameor the SAML NameID, depending on how the server's authentication is configured. - To check the exact value for a given user: as an admin, go to Users on the site, and look at the Name column (not "Full Name") in the users table — that's the string Tableau expects.
- A mismatch here doesn't error on JWT signing — it fails at sign-in with an authentication error, since Tableau simply can't find a matching user.
Connected App scopes — a Connected App JWT is scoped, and there is no generic "write everything" scope; each action needs its own scope from Tableau's scope reference. Common ones:
| Operation | Scope(s) |
|---|---|
| Read/list workbooks, users, projects, etc. | tableau:content:read |
| Create/update/delete users | tableau:users:create, tableau:users:update, tableau:users:delete (or tableau:users:*) |
Grant/revoke permissions (permissions()->grant()/revoke()) |
tableau:permissions:update |
| Publish/update data sources | tableau:datasources:create, tableau:datasources:update |
| Publish/update workbooks | tableau:workbooks:create, tableau:workbooks:update |
| Create/update projects | tableau:projects:create, tableau:projects:update (or tableau:projects:*) |
Request only the scopes your integration actually needs, and add more as you add functionality — Tableau will reject calls with 401s if a needed scope is missing.
A 401 (not 403) on an already-authenticated session is very likely a missing-scope issue, not a credentials issue. Sign-in succeeding proves the client ID/secret/user are valid; a subsequent 401 on a specific endpoint (e.g.
users()->add()orpermissions()->grant()) almost always means the JWT's scopes don't cover that action. Add the scope from the table above and retry before assuming anything else is wrong.
Personal Access Token (simpler alternative, tied to one user's account):
- Sign in to Tableau in your browser.
- Open your account menu (top right) → My Account Settings.
- Scroll to Personal Access Tokens and enter a name for the new token, then click Create new token.
- Tableau shows the token's secret exactly once — copy it immediately into
TABLEAU_PAT_SECRET; the token's name (what you typed in step 3) goes inTABLEAU_PAT_NAME. - PATs expire after 15 consecutive days of inactivity (Tableau Cloud) or per your server's configured policy — if requests start failing with an authentication error, generate a new one.
If neither is available, fall back to username/password — but note Tableau is phasing this out for Tableau Cloud and it requires the site to allow it.
If more than one of these is configured at once, the client prefers Connected App over PAT over username/password.
API version — Tableau's REST API is versioned independently from the product version. Check the REST API and Resources Versions page for the version matching your server, or query GET https://<server>/api/metadata/graphql's sibling GET https://<server>/api/-/serverInfo (no auth required) which returns the server's supported restApiVersion directly. 3.24 (the default here) works against any reasonably current Tableau Server/Cloud.
Impersonating a dynamic user
With a Connected App, the server-level credentials (client id, secret id, secret value) are fixed and belong in config/env — but who you're impersonating often isn't known until runtime, e.g. it should be whichever user is logged into your own application. Configure everything except connected_app_user/TABLEAU_CONNECTED_APP_USER, then call signInAs() with the user per-request:
$tableau = new TableauClient([ 'server_url' => 'https://tableau.example.com', 'connected_app_client_id' => 'client-id', 'connected_app_secret_id' => 'secret-id', 'connected_app_secret_value' => 'secret-value', // no connected_app_user — supplied dynamically below ]); $tableau->signInAs($currentUser->tableau_username); foreach ($tableau->workbooks()->list() as $workbook) { ... }
signInAs() always re-authenticates (it doesn't reuse a previous session), since a different call may need to impersonate a different user than the last one. Pass scopes as a second argument to override connected_app_scopes per call if needed.
Session memoization
signIn() is memoized: once authenticated, calling it again is a no-op as long as the session hasn't expired, so it's safe to call at the start of every method/request without worrying about extra round trips.
$tableau->signIn(); // hits the network, authenticates $tableau->signIn(); // no-op — session still valid $tableau->signIn(force: true); // always re-authenticates
Expiry is tracked from Tableau's estimatedTimeToExpiration (falling back to Tableau's default 240-minute session timeout), with a 60-second safety buffer. This memoization is in-memory only and scoped to a single TableauClient instance — in a non-persistent runtime (e.g. a typical PHP-FPM/Laravel request), a fresh process still re-authenticates once per request.
Laravel integration (optional)
The package auto-discovers FoundryCo\TableauApi\Laravel\TableauServiceProvider. Publish the config file:
php artisan vendor:publish --tag=tableau-config
Set the relevant TABLEAU_* environment variables (see config/tableau.php):
TABLEAU_SERVER_URL=https://tableau.example.com
TABLEAU_SITE=marketing
# Connected App (preferred) — or use TABLEAU_PAT_NAME/TABLEAU_PAT_SECRET instead
TABLEAU_CONNECTED_APP_CLIENT_ID=client-id
TABLEAU_CONNECTED_APP_SECRET_ID=secret-id
TABLEAU_CONNECTED_APP_SECRET_VALUE=secret-value
TABLEAU_CONNECTED_APP_USER=service-account@example.com
Then resolve the client via the container, dependency injection, or the Tableau facade:
use FoundryCo\TableauApi\Laravel\Tableau; use FoundryCo\TableauApi\TableauClient; // Facade Tableau::signIn(); $workbooks = Tableau::workbooks()->list(); // Container / constructor injection class ReportSyncController { public function __construct(private TableauClient $tableau) {} }
The bound TableauClient is a singleton per application lifecycle, so calling signIn() repeatedly (e.g. at the top of every request) is cheap — see Session memoization below.
Resources
Each resource class is accessed from TableauClient and mirrors a section of the REST API reference:
| Method | Resource | Docs |
|---|---|---|
sites() |
Resources\Sites |
Sites |
projects() |
Resources\Projects |
Projects |
users() |
Resources\Users |
Users and Groups |
groups() |
Resources\Groups |
Users and Groups |
workbooks() |
Resources\Workbooks |
Workbooks and Views |
datasources() |
Resources\Datasources |
Data Sources |
views() |
Resources\Views |
Workbooks and Views |
jobs() |
Resources\Jobs |
Jobs, Tasks, and Schedules |
schedules() |
Resources\Schedules |
Jobs, Tasks, and Schedules |
permissions() |
Resources\Permissions |
Permissions |
flows() |
Resources\Flows |
Flows |
subscriptions() |
Resources\Subscriptions |
Subscriptions |
favorites() |
Resources\Favorites |
Favorites |
List endpoints return PHP Generators that transparently walk every page:
foreach ($tableau->workbooks()->list(filterExpression: 'name:eq:Sales Overview') as $workbook) { // one HTTP request per page of 100, fetched lazily as you iterate }
Every resource method returns either the decoded array Tableau sent back, or a FoundryCo\TableauApi\Http\Response (a small read-only DTO with json(), body(), status(), header(), successful()/failed()) when you might want status codes/headers — check each method's return type.
Workflow helpers
Several Tableau operations require multiple, ordered REST calls. The client wraps the common ones so you don't have to hand-roll them:
Publish a workbook/datasource and wait for its extract refresh
$result = $tableau->publishAndRefresh()->workbook( filename: 'sales.twbx', contents: file_get_contents('sales.twbx'), projectId: $projectId, overwrite: true, ); // $result['workbook'] — the published workbook payload // $result['job'] — the finished refresh job payload
Refresh and wait for completion
$job = $tableau->refreshAndAwait()->workbook($workbookId, pollSeconds: 5, timeoutSeconds: 900); // or ->datasource($datasourceId) / ->flow($flowId) / ->await($existingJobId)
Provision a user (create + add to groups + grant project permissions)
$user = $tableau->provisionUser()->run( name: 'alice@example.com', siteRole: 'Explorer', groupIds: [$analystsGroupId], projectPermissions: [ $marketingProjectId => ['Read', 'Filter', 'ViewComments'], ], );
Clone permissions from one content item to another
$tableau->clonePermissions()->run( contentType: 'projects', sourceId: $templateProjectId, targetId: $newProjectId, );
Error handling
Failed API calls throw FoundryCo\TableauApi\Exceptions\TableauApiException, which exposes the Tableau error code/detail and the underlying HTTP response:
use FoundryCo\TableauApi\Exceptions\TableauApiException; try { $tableau->projects()->create('Marketing'); } catch (TableauApiException $e) { report($e->tableauErrorCode); // e.g. "429006" report($e->tableauDetail); report($e->httpStatus); }
Configuration/usage errors (e.g. missing credentials) throw FoundryCo\TableauApi\Exceptions\TableauException.
Testing your own code against this client
Because the client is built directly on Guzzle, you can inject a client using Guzzle's MockHandler in tests:
use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response as PsrResponse; $mock = new MockHandler([ new PsrResponse(200, [], json_encode(['credentials' => [ 'token' => 'fake-token', 'site' => ['id' => 'site-id', 'contentUrl' => ''], 'user' => ['id' => 'user-id'], ]])), new PsrResponse(200, [], json_encode(['projects' => ['project' => []], 'pagination' => ['totalAvailable' => 0]])), ]); $http = new GuzzleClient(['handler' => HandlerStack::create($mock)]); $tableau = new TableauClient(['server_url' => 'https://tableau.example.com'], $http); $tableau->signIn();
License
MIT