bdevs / supabase-laravel
Laravel package for integrating with Supabase (Auth, Storage, Edge Functions).
Requires
- php: ^8.4
- guzzlehttp/guzzle: ^7.8|^8.0
- illuminate/auth: ^11.0|^12.0|^13.0
- illuminate/broadcasting: ^11.0|^12.0|^13.0
- illuminate/bus: ^11.0|^12.0|^13.0
- illuminate/cache: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/events: ^11.0|^12.0|^13.0
- illuminate/filesystem: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/notifications: ^11.0|^12.0|^13.0
- illuminate/queue: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- illuminate/validation: ^11.0|^12.0|^13.0
- laravel/prompts: ^0.1|^0.2|^0.3
- laravel/serializable-closure: ^1.3|^2.0
- league/flysystem: ^3.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Use Supabase from a Laravel app. Query your database through the Supabase API, sign users in, store files, send Realtime messages, call Edge Functions, and run SQL migrations.
use BlackDevs\SupabaseLaravel\Facades\Supabase; $orders = Supabase::table('orders') ->select('id,total,status') ->eq('status', 'paid') ->orderBy('created_at', 'desc') ->limit(10) ->get();
Contents
Get started
Use the package
- Query data
- Call Postgres functions (RPC)
- Handle errors
- Authentication
- Storage
- Edge Functions
- Realtime
- Migrations, functions and seeders
- Laravel integrations
- Test your app
Reference
Requirements
- PHP 8.4 or newer
- Laravel 11, 12 or 13
- A Supabase project
- The
pdo_pgsqlPHP extension, only if you use a direct database connection
Installation
composer require bdevs/supabase-laravel
Connect to your database
How the connection works
The package reaches Supabase in two ways, and Eloquent is separate from both. Find what you want to do in this table, then set up only what it needs.
| You want to | Connection | What you need |
|---|---|---|
Read and write rows with Supabase::table() or Supabase::rpc(), or use Auth, Storage, Realtime and Edge Functions |
HTTPS to the Supabase APIs | SUPABASE_URL and API keys |
Change the schema with supabase:migrate or supabase:push-functions, or run SQL seeders |
Direct Postgres connection | SUPABASE_DB_URL and pdo_pgsql |
Use Eloquent models, the DB facade or php artisan migrate |
Laravel's own pgsql connection, not this package |
DB_* settings |
Most apps start with the first row only.
Eloquent does not go through this package.
Supabase::table()returns plain arrays and does not useDB_CONNECTION.User::all()still uses whateverDB_CONNECTIONis set to. To point Eloquent at Supabase, follow step 5.
Step 1: Get your credentials
Open your project in the Supabase dashboard.
.env variable |
Value | Where to find it |
|---|---|---|
SUPABASE_URL |
https://<project-ref>.supabase.co |
The project ref is in the dashboard address: supabase.com/dashboard/project/<project-ref> |
SUPABASE_ANON_KEY |
Publishable key (sb_publishable_...) or legacy anon key |
Project Settings > API Keys |
SUPABASE_SERVICE_ROLE_KEY |
Secret key (sb_secret_...) or legacy service_role key |
Project Settings > API Keys |
SUPABASE_JWT_SECRET |
Legacy JWT secret. Only needed for the supabase auth guard |
Project Settings > JWT Keys |
Keep the secret key on the server. It bypasses all Row Level Security. Never put it in JavaScript, in a
VITE_*variable, or in Git.
Step 2: Add them to .env
Run the installer:
php artisan supabase:install
It asks for your URL and keys, writes them to .env, publishes config/supabase.php, and offers to test the connection. It does not ask for SUPABASE_DB_URL.
Or add them by hand:
SUPABASE_URL=https://abcdefghijkl.supabase.co SUPABASE_ANON_KEY=sb_publishable_xxx SUPABASE_SERVICE_ROLE_KEY=sb_secret_xxx
Publishing the config file is optional. You only need it to add a second connection:
php artisan vendor:publish --tag=supabase-config
Step 3: Test the connection
php artisan supabase:test
+-----------+--------+------+---------+-------+
| Check | Status | HTTP | Latency | Error |
+-----------+--------+------+---------+-------+
| PostgREST | OK | 200 | 86 ms | |
| Auth | OK | 200 | 47 ms | |
| Storage | OK | 200 | 59 ms | |
+-----------+--------+------+---------+-------+
This proves your server can reach the project. It does not prove your keys are correct, because any response below HTTP 500 counts as OK, including 401. It also does not check SUPABASE_DB_URL. The command exits with code 1 on failure, so you can use it in CI.
To test your keys, run a real query:
php artisan tinker --execute 'dump(Supabase::table("your_table")->limit(1)->get());'
| Result | Meaning |
|---|---|
| A collection, even an empty one | Everything works |
Invalid API key |
The key is wrong or empty |
Could not find the table |
Your keys work, but the table name is wrong |
Could not resolve host |
SUPABASE_URL is wrong, or the project is paused or removed |
Step 4: Add a direct Postgres connection (optional)
You only need this for migrations, pushing functions and SQL seeders, because the Supabase REST API cannot run schema changes.
1. Install the PHP extension. Check that pdo_pgsql is enabled:
php -m | grep pdo_pgsql
Composer does not check for this extension, so a missing one only shows up later as could not find driver.
2. Copy a connection string. In the dashboard, click Connect and choose a mode:
| Mode | Host and port | Works over IPv4 | Use it for |
|---|---|---|---|
| Session pooler (recommended) | aws-<n>-<region>.pooler.supabase.com:5432 |
Yes | These commands, and Eloquent on most servers |
| Transaction pooler | aws-<n>-<region>.pooler.supabase.com:6543 |
Yes | Serverless apps with many short connections. No prepared statements |
| Direct connection | db.<project-ref>.supabase.co:5432 |
Only with the paid IPv4 add-on | Servers that have IPv6 |
Copy the host from the dashboard. You cannot work out the <n> in the pooler host from your region. With the poolers, the username is postgres.<project-ref>, not just postgres.
3. Add it to .env:
SUPABASE_DB_URL="postgresql://postgres.abcdefghijkl:YOUR-PASSWORD@aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres"
Use your database password here, not an API key. You can reset it under Project Settings > Database. The commands always connect with sslmode=require.
4. Encode special characters in the password. A password containing @ # / : ? % + must be percent-encoded, or the connection fails. An unencoded + is the hardest to spot, because it silently becomes a space. This command prints the encoded form:
php -r 'echo rawurlencode($argv[1]), PHP_EOL;' 'my#pass+word' # my%23pass%2Bword
5. Test it:
php artisan tinker --execute 'config(["database.connections.supabase_check" => ["driver" => "pgsql", "url" => config("supabase.connections.main.db_url"), "sslmode" => "require"]]); dump(DB::connection("supabase_check")->selectOne("select current_user, current_database()"));'
This uses a temporary connection that exists only for that command. It prints your database user if the connection works. See Troubleshooting if it fails.
Step 5: Use Eloquent with Supabase (optional)
Supabase is a normal Postgres database, so Laravel can connect to it directly. Set this in .env, using the same session pooler string:
DB_CONNECTION=pgsql DB_URL="postgresql://postgres.abcdefghijkl:YOUR-PASSWORD@aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres" DB_SSLMODE=require
Then check it:
php artisan db:show
Secure your tables. Any table in the
publicschema is reachable through the Supabase API. Tables created with SQL, which includesphp artisan migrate, do not get Row Level Security automatically. Until you enable it, anyone with your publishable key can read and write every row, includingusersandsessions.Find the unprotected tables in the Supabase SQL editor:
select tablename, rowsecurity from pg_tables where schemaname = 'public' order by tablename;Then enable RLS on each one:
alter table public.users enable row level security;Eloquent keeps working if you connect as
postgres, because the table owner is not restricted by RLS.
Using the transaction pooler with Eloquent. Port 6543 does not support prepared statements, so Laravel must emulate them. On Laravel 13, add direct to the pgsql connection in config/database.php. Laravel then emulates prepares and sends migrations through the direct settings:
'pgsql' => [ // ...existing settings... 'direct' => array_filter([ 'port' => env('DB_DIRECT_PORT'), ]), ],
DB_PORT=6543 DB_DIRECT_PORT=5432
Set DB_HOST, DB_DATABASE, DB_USERNAME and DB_PASSWORD separately for this setup. Port 5432 on the same pooler host is the session pooler, which can run migrations.
If your Laravel version has no pooled mode, add 'options' => [PDO::ATTR_EMULATE_PREPARES => true] to the connection instead.
Connect to more than one project
Publish the config file, then add a connection to config/supabase.php:
'connections' => [ 'main' => [ /* ... */ ], 'staging' => [ 'url' => env('SUPABASE_STAGING_URL'), 'anon_key' => env('SUPABASE_STAGING_ANON_KEY'), 'service_role_key' => env('SUPABASE_STAGING_SERVICE_ROLE_KEY'), 'jwt_secret' => env('SUPABASE_STAGING_JWT_SECRET'), 'db_url' => env('SUPABASE_STAGING_DB_URL'), ], ],
Pass the name as the last argument, or as --connection on a command:
Supabase::table('orders', 'staging')->get(); Supabase::asUser($token, 'staging')->from('orders')->get(); Supabase::rpc('calculate_total', ['id' => 1], 'staging');
php artisan supabase:test --connection=staging php artisan supabase:migrate --connection=staging
Set SUPABASE_CONNECTION=staging to change the default connection.
Do not run
supabase:install --connection=stagingto set this up. It always writes toSUPABASE_URLand the other main variables, so it overwrites yourmaincredentials. Add theSUPABASE_STAGING_*values to.envby hand.
Choose who the request runs as
Every request runs as one of three roles. The choice decides whether Row Level Security (RLS) applies, so make it deliberately.
| Call | Runs as | RLS | Use it for |
|---|---|---|---|
Supabase::table('orders') or Supabase::serviceRole()->from('orders') |
Service role, using the secret key | Bypassed | Trusted server code such as jobs and admin tasks |
Supabase::asUser($token)->from('orders') |
The signed-in user | Enforced | Anything done on behalf of a user |
Supabase::anonymous()->from('orders') |
Anonymous visitor, using the publishable key | Enforced | Public data |
Supabase::table()ignores RLS. It is a shortcut for the service role, so your code must do its own permission checks. In a request made by a user, preferasUser():$todos = Supabase::asUser($request->bearerToken())->from('todos')->get();
Query data
Read rows
$users = Supabase::table('users') ->select('id,name,email') ->eq('active', true) ->orderBy('name') // or ->orderBy('name', 'desc') ->limit(10) ->offset(20) ->get(); // Illuminate\Support\Collection of arrays Supabase::table('users')->eq('id', $id)->single(); // array. Throws unless exactly one row matches Supabase::table('users')->eq('id', $id)->maybeSingle(); // array or null
Paginate
$page = Supabase::table('users')->paginate(perPage: 15, page: 2); // ['data' => [...], 'page' => 2, 'per_page' => 15, 'total' => 42, 'last_page' => 3]
This returns a plain array, not a Laravel paginator.
Filter
->eq('column', $value) ->neq('column', $value) ->gt('column', $value) ->gte('column', $value) ->lt('column', $value) ->lte('column', $value) ->in('column', [1, 2, 3]) ->is('column', null) // also true or false ->like('column', 'Ada%') ->ilike('column', '%example.com') // case-insensitive ->contains('tags', ['admin', 'staff']) // array contains these values ->where('column', 'operator', $value) // any other PostgREST operator
Filters on different columns are combined with AND.
Use only one filter per column. A second filter on the same column replaces the first, with no error:
// Sends only age <= 65. The age >= 18 filter is lost. Supabase::table('people')->gte('age', 18)->lte('age', 65)->get();This also affects
update()anddelete(), where it can change more rows than you intended. For a range, use a Postgres function.
OR conditions:
Supabase::table('users')->orWhere([ 'status' => ['eq', 'active'], 'role' => ['eq', 'admin'], ])->get(); // WHERE status = 'active' OR role = 'admin'
Each column can appear once, because PHP array keys must be unique. Call orWhere() only once per query: a second call produces a filter that PostgREST cannot parse.
Insert, update, upsert and delete
Supabase::table('users')->insert(['name' => 'Ada'])->get(); Supabase::table('users')->insert([['name' => 'Ada'], ['name' => 'Grace']])->get(); // many rows Supabase::table('users')->eq('id', $id)->update(['name' => 'Ada Lovelace'])->get(); Supabase::table('users')->upsert(['email' => 'ada@example.com', 'name' => 'Ada'], onConflict: ['email'])->get(); Supabase::table('users')->eq('id', $id)->delete()->get();
Always finish with
->get().insert(),update(),upsert()anddelete()only build the request. Nothing is sent until you callget(),single()ormaybeSingle().
insert, update and upsert return the affected rows. Always add a filter before update() or delete().
Related tables
There is no join(). Select related tables through their foreign keys:
$orders = Supabase::table('orders') ->select('id,total,user:users(id,name,email)') ->eq('status', 'paid') ->get(); $orders->first()['user']['name'];
A filter on a related table narrows the embedded rows, not the parent rows. To remove parent rows as well, use !inner:
Supabase::table('orders') ->select('id,total,user:users!inner(id,name)') ->eq('user.active', true) ->get();
Typed results
Map rows to a class instead of arrays. Columns you do not declare are ignored.
use BlackDevs\SupabaseLaravel\Support\SupabaseResource; class Order extends SupabaseResource { public int $id; public ?string $note = null; } $orders = Supabase::table('orders')->getAs(Order::class); // Collection of Order $order = Supabase::table('orders')->eq('id', 1)->singleAs(Order::class); // Order $order = Supabase::table('orders')->eq('id', 1)->maybeSingleAs(Order::class); // Order or null $order->toArray();
php artisan supabase:schema-syncis meant to generate these classes. It currently requests the project root instead of/rest/v1/, so it does not reach your schema. Write the classes by hand for now.
Call Postgres functions (RPC)
$total = Supabase::rpc('calculate_total', ['order_id' => $orderId]); // Respect RLS by running as the user: $total = Supabase::asUser($token)->rpc('calculate_total', ['order_id' => $orderId]);
The function must already exist in your database. See Postgres functions to create one.
Handle errors
If Supabase returns an error, the package throws a subclass of SupabaseException. If Supabase cannot be reached, Laravel throws ConnectionException, which is not a SupabaseException, so catch both.
use BlackDevs\SupabaseLaravel\Exceptions\SupabaseException; use Illuminate\Http\Client\ConnectionException; try { $orders = Supabase::table('orders')->get(); } catch (SupabaseException $e) { // Supabase replied with an error $e->getMessage(); // "Invalid API key" $e->status(); // 401 $e->body(); // decoded response body } catch (ConnectionException $e) { // Supabase could not be reached }
| Exception | Thrown by |
|---|---|
SupabasePostgrestException |
Queries and RPC |
SupabaseAuthException |
Supabase::auth() |
SupabaseStorageException |
Supabase::storage() |
SupabaseFunctionsException |
Edge Functions |
SupabaseRealtimeException |
Realtime broadcast |
Connection failures and HTTP 429 or 503 responses are retried automatically. Other errors are not. See SUPABASE_HTTP_RETRY_TIMES in the configuration reference.
Authentication
Sign users in
Supabase::auth() needs both the publishable and the secret key.
$auth = Supabase::auth(); $auth->signUp('ada@example.com', 'password', ['name' => 'Ada']); $session = $auth->signInWithPassword('ada@example.com', 'password'); $session['access_token']; $session['refresh_token']; $session['user']; $session = $auth->refreshToken($session['refresh_token']); $auth->getUser($accessToken); $auth->updateUser($accessToken, ['data' => ['name' => 'Ada Lovelace']]); $auth->signOut($accessToken); $auth->signInWithMagicLink('ada@example.com', redirectTo: 'https://example.com/welcome'); $auth->verifyOtp('ada@example.com', $code, type: 'magiclink'); $auth->resetPasswordForEmail('ada@example.com', redirectTo: 'https://example.com/reset'); return redirect($auth->oauthAuthorizeUrl('github', redirectTo: 'https://example.com/callback')); $auth->deleteUser($userId); // permanent
Protect routes with the supabase guard
The guard reads the Authorization: Bearer <token> header and verifies the JWT locally, without calling Supabase.
1. Set SUPABASE_JWT_SECRET in .env.
2. Add the guard to config/auth.php:
'guards' => [ // ... 'supabase' => [ 'driver' => 'supabase', 'connection' => null, // optional: a connection name from config/supabase.php ], ],
3. Protect your routes with auth:supabase:
Route::middleware('auth:supabase')->get('/me', function (Request $request) { $user = $request->user(); return [ 'id' => $user->getAuthIdentifier(), // the JWT "sub" claim 'email' => $user->email, // any claim, as a property 'claims' => $user->claims(), ]; });
Clients should send Accept: application/json. Otherwise Laravel tries to redirect guests to a login page instead of returning 401.
Use
auth:supabase, not thesupabase.authalias.supabase.authalso blocks guests, but it does not makesupabasethe active guard. Behind it,$request->user()returnsnulland every Gate check is denied. If you must use it, read the user with$request->user('supabase').
The guard only verifies HS256 tokens. If your project uses asymmetric signing keys (ES256 or RS256), every token is rejected with a 401, even a valid one. To check, decode the header of an access token and look at
alg. With asymmetric keys, skip the guard and validate tokens withSupabase::auth()->getUser($token). That works with any algorithm, but makes a network call each time.
Roles, Gates and Policies
Role helpers read from the verified token, so they do not run a query:
$user->role(); // app_metadata.role, or else the "role" claim $user->roles(); // app_metadata.roles, or else [role()] $user->hasRole('admin'); $user->hasAnyRole(['admin', 'editor']);
use BlackDevs\SupabaseLaravel\Auth\SupabaseUser; use Illuminate\Support\Facades\Gate; // In AppServiceProvider::boot() Gate::define('manage-billing', fn (SupabaseUser $user) => $user->hasRole('admin'));
Set roles in app_metadata, using the Admin API or an Auth Hook. Never use user_metadata, because users can edit it themselves. Without an app role, role() returns the Postgres role, usually authenticated.
Storage
Upload, download and manage files
Supabase::storage() uses the secret key. Pass a user's token to apply their storage policies instead: Supabase::storage($token).
$storage = Supabase::storage(); $storage->upload('avatars', 'users/1.png', file_get_contents($path), 'image/png'); $storage->upload('avatars', 'users/1.png', $bytes, 'image/png', upsert: true); // overwrite // From a form. Generates a unique file name and detects the type. $result = $storage->uploadFile('avatars', $request->file('image'), directory: 'users'); $bytes = $storage->download('avatars', 'users/1.png'); $storage->delete('avatars', ['users/1.png', 'users/2.png']); $storage->move('avatars', 'users/1.png', 'archive/1.png'); $storage->copy('avatars', 'users/1.png', 'backups/1.png'); $objects = $storage->list('avatars', 'users', ['limit' => 100, 'offset' => 0]);
File URLs
// Public bucket. Builds the URL without making a request. $url = $storage->getPublicUrl('avatars', 'users/1.png'); // Private bucket. The URL expires. $url = $storage->createSignedUrl('avatars', 'users/1.png', expiresInSeconds: 60); // Lets a client upload directly, without your secret key. $upload = $storage->createSignedUploadUrl('avatars', 'users/1.png');
createSignedUrl()currently returns an incomplete URL. It leaves out/storage/v1, so the link will not work. Add it back until this is fixed:$url = str_replace('.supabase.co/object/', '.supabase.co/storage/v1/object/', $url);
Buckets
$storage->createBucket('avatars', ['public' => true, 'fileSizeLimit' => 5 * 1024 * 1024]); $storage->getBucket('avatars'); $storage->listBuckets(); $storage->updateBucket('avatars', ['public' => false]); $storage->emptyBucket('avatars'); $storage->deleteBucket('avatars');
Use it as a Laravel disk
Add a disk to config/filesystems.php:
'disks' => [ // ... 'supabase' => [ 'driver' => 'supabase', 'bucket' => env('SUPABASE_STORAGE_BUCKET'), 'connection' => null, 'throw' => true, ], ],
Storage::disk('supabase')->put('users/1.png', $bytes, ['mimetype' => 'image/png']); Storage::disk('supabase')->get('users/1.png'); Storage::disk('supabase')->delete('users/1.png');
The disk has these limits:
put()always overwrites an existing file.- Files are stored as
application/octet-streamunless you passmimetype. url()throwsThis driver does not support retrieving URLs.UseSupabase::storage()->getPublicUrl()instead.exists(),size(),mimeType(),lastModified()and folder listings only look at the first 100 objects in a folder. A file beyond that looks like it does not exist.
Edge Functions
// As an anonymous visitor $result = Supabase::invoke('hello', ['name' => 'Ada']); // As a signed-in user $result = Supabase::invoke('hello', ['name' => 'Ada'], accessToken: $token); // With extra headers $result = Supabase::functions($token)->invoke('hello', ['name' => 'Ada'], ['x-region' => 'eu']);
The result is the decoded JSON response, or the raw body if the response is not JSON.
An anonymous call sends your API key as the bearer token. New sb_ keys are not JWTs, so a function that verifies the JWT in its own code will reject the call. Pass a user's token for those functions.
Realtime
Laravel can send Realtime messages over HTTP. Receiving needs a WebSocket, which a PHP request cannot hold open, so that part happens in the browser.
Send from Laravel
Supabase::channel('room-1')->broadcast('message', ['text' => 'hi']); Supabase::channel('orders.42')->broadcast('status.updated', ['status' => 'shipped'], private: true);
Use Laravel's broadcasting system
Run php artisan config:publish broadcasting if you do not have config/broadcasting.php, then add a connection:
'connections' => [ // ... 'supabase' => [ 'driver' => 'supabase', 'connection' => null, ], ],
BROADCAST_CONNECTION=supabase
Now events that implement ShouldBroadcast are sent through Supabase:
class OrderShipped implements ShouldBroadcast { public function __construct(public Order $order) {} public function broadcastOn(): array { return [new PrivateChannel('orders.'.$this->order->id)]; } }
routes/channels.php is Laravel's own check. It does not stop anyone reading from Supabase's socket directly. To enforce that as well, add RLS policies on realtime.messages.
Receive in the browser
This package ships no JavaScript. Install the official client:
npm install @supabase/supabase-js
VITE_SUPABASE_URL="${SUPABASE_URL}" VITE_SUPABASE_ANON_KEY="${SUPABASE_ANON_KEY}"
import { createClient } from '@supabase/supabase-js'; const supabase = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY, ); // Public channel const room = supabase .channel('room-1') .on('broadcast', { event: 'message' }, ({ payload }) => console.log(payload.text)) .subscribe(); // Database changes. The table must be enabled for Realtime in Supabase. supabase .channel('new-messages') .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: 'room_id=eq.1' }, (change) => console.log(change.new), ) .subscribe(); // Stop listening await supabase.removeChannel(room);
Private channels need the user's token, plus a select policy on realtime.messages. Without that policy, messages never arrive and you get no error.
await supabase.realtime.setAuth(userAccessToken); supabase .channel('orders.42', { config: { private: true } }) .on('broadcast', { event: 'status.updated' }, ({ payload }) => console.log(payload)) .subscribe();
create policy "authenticated can receive broadcasts" on realtime.messages for select to authenticated using (true);
This example policy lets any signed-in user read every private channel. Narrow it for real use. See Realtime Authorization.
Migrations, functions and seeders
These commands are separate from Laravel's own migrations. The ones that run SQL connect directly to Supabase, so they need SUPABASE_DB_URL. They all accept --connection and --dry-run, and a dry run does not need a database connection. The make commands only create files.
Table migrations
1. Create a file:
php artisan supabase:make-table create_invoices_table
# database/supabase/tables/2026_01_15_113537_create_invoices_table.sql
Names like create_x_table and add_y_to_x_table get working SQL. Any other name gets an empty template.
2. Edit it. The -- up section runs on migrate, and -- down runs on rollback.
-- up create table if not exists public.invoices ( id bigint generated by default as identity primary key, total numeric not null, created_at timestamptz not null default now() ); alter table public.invoices enable row level security; grant select, insert, update, delete on public.invoices to anon, authenticated, service_role; -- down drop table if exists public.invoices;
Add the
enable row level securityline yourself. The generated file grants full access toanonand does not enable RLS. Without that line, anyone with your publishable key can read and change every row. With RLS on and no policies, only your secret key has access, until you add policies.
3. Run it:
php artisan supabase:migrate --dry-run # see what would run php artisan supabase:migrate # run everything pending php artisan supabase:migrate invoices # only files with "invoices" in the name
4. Roll back if needed:
php artisan supabase:rollback-table # the last batch php artisan supabase:rollback-table --step=2 # the last 2 batches php artisan supabase:rollback-table add_paid_to_invoices_table # just this one
Things to know before relying on these commands:
- History is stored in a local file, not in the database. It lives at
storage/framework/supabase-migrations-{connection}.json. On a new machine, CI runner or server with fresh storage, every migration looks pending and runs again. Either commit this file, or always migrate from one place. - A failed run forgets what it finished. If the third file fails, the first two are applied but not recorded, so the next run applies them again. Make migrations safe to repeat, with
create table if not existsandadd column if not exists. - Avoid comments that start with
-- upor-- down. The markers are matched by prefix, so-- update usersor-- download countsplits the file in the wrong place. A migration with an empty-- upsection is skipped with a warning. - Rollback runs the saved copy of
-- downfrom when the migration was applied, not the current file.
Postgres functions
php artisan supabase:make-function calculate_total
# database/supabase/functions/calculate_total.sql
Edit the file, then push it. Only files that changed since the last push are applied.
php artisan supabase:push-functions --dry-run
php artisan supabase:push-functions
php artisan supabase:push-functions --force # push everything
Call it with Supabase::rpc('calculate_total', [...]).
To undo, restore the previous version. The last 10 versions of each file are kept, and each rollback goes one step further back:
php artisan supabase:rollback-function calculate_total
Check the generated function's permissions. The template uses
security definer, so the function runs with its owner's rights and ignores RLS. Functions inpubliccan also be called through the API with your publishable key. If the function is for your server only, add this to the file, using your function's real parameter types:revoke execute on function public.calculate_total(bigint) from public, anon, authenticated;
As with migrations, history is stored in local files under storage/framework/, and a failed run does not record the files it finished.
Seeders
php artisan supabase:make-seeder invoices # PHP class, goes through the API php artisan supabase:make-seeder invoices --sql # raw SQL file, goes through SUPABASE_DB_URL php artisan supabase:seed # run all php artisan supabase:seed invoices # only matching seeders
// database/supabase/seeders/InvoicesSeeder.php class InvoicesSeeder { public function run(): void { Supabase::table('invoices')->insert([ ['total' => 100], ['total' => 250], ])->get(); } }
SQL seeders run first, then PHP seeders. Seeders are not tracked, so each run repeats them. Write them to be safe to repeat, for example with upsert().
Laravel integrations
Events
Event (in BlackDevs\SupabaseLaravel\Events) |
Fired by | Properties |
|---|---|---|
UserSignedUp |
auth()->signUp() |
$response |
UserSignedIn |
auth()->signInWithPassword() |
$session |
UserSignedOut |
auth()->signOut() |
$accessToken |
FileUploaded |
storage()->upload(), uploadFile() |
$bucket, $path, $response |
FileDeleted |
storage()->delete() |
$bucket, $paths |
Event::listen(UserSignedUp::class, function (UserSignedUp $event) { // $event->response['user'] });
Queries do not fire events.
Queue
Run a Supabase call in the background so it cannot slow down or fail the request:
use BlackDevs\SupabaseLaravel\Jobs\CallSupabase; CallSupabase::dispatch(fn () => Supabase::table('audit_log')->insert([ 'action' => 'order.shipped', 'order_id' => $order->id, ])->get());
It is a normal queued job, so ->onQueue() and ->delay() work.
Notifications
Deliver a notification as a private Realtime message:
use BlackDevs\SupabaseLaravel\Notifications\SupabaseChannel; class OrderShipped extends Notification { public function __construct(public Order $order) {} public function via(mixed $notifiable): array { return [SupabaseChannel::class]; } public function toSupabase(mixed $notifiable): array { return ['order_id' => $this->order->id]; } }
The message goes to notifications.{id} with the event name notification. To use another topic, add receivesSupabaseBroadcastsOn(Notification $notification): string to the notifiable model. The browser needs a private channel to receive it.
Validation
use BlackDevs\SupabaseLaravel\Validation\SupabaseUnique; $request->validate([ 'email' => ['required', 'email', new SupabaseUnique('users')], 'slug' => [(new SupabaseUnique('posts', 'slug'))->ignore($post->id)], 'code' => [(new SupabaseUnique('coupons'))->connection('staging')], ]);
Cache
This store does not work on Laravel 13.
SupabaseStoreis missing thetouch()method that Laravel 13 requires, so using it causes a fatal error. Use another cache store on Laravel 13.
On earlier versions, create the table and keep it private:
create table public.cache ( key text primary key, value text not null, expiration bigint ); alter table public.cache enable row level security;
Add the store to config/cache.php:
'stores' => [ // ... 'supabase' => [ 'driver' => 'supabase', 'table' => 'cache', 'connection' => null, 'prefix' => '', ], ],
Cache::store('supabase')->put('key', 'value', 60);
increment() and decrement() are not atomic, so do not use them for counters that must be exact.
Webhooks
Supabase Database Webhooks do not sign their requests, so the package checks a shared secret sent as a header.
1. Set the secret in .env:
SUPABASE_WEBHOOK_SECRET=a-long-random-string
2. In the Supabase dashboard, add an HTTP header to the webhook: X-Supabase-Signature: a-long-random-string.
3. Protect the route. Put it in routes/api.php, because routes in web.php also need a CSRF exception.
Route::post('/webhooks/supabase', SupabaseWebhookController::class)->middleware('supabase.webhook');
The route returns 401 if the header is wrong, and 500 if the secret is not set.
Multi-tenancy
Scope queries to the tenant in the signed-in user's token. The filter is only applied to queries built with this trait, and those queries run with the secret key, so RLS will not catch a query you forget to scope.
1. Add a tenant claim with a Custom Access Token Hook. This example assumes a public.tenant_users table that maps users to tenants:
create or replace function public.custom_access_token_hook(event jsonb) returns jsonb language plpgsql stable as $$ declare claims jsonb; current_tenant uuid; begin select tenant_id into current_tenant from public.tenant_users where user_id = (event->>'user_id')::uuid; claims := event->'claims'; claims := jsonb_set(claims, '{tenant_id}', coalesce(to_jsonb(current_tenant), 'null')); return jsonb_set(event, '{claims}', claims); end; $$; grant usage on schema public to supabase_auth_admin; grant execute on function public.custom_access_token_hook to supabase_auth_admin; revoke execute on function public.custom_access_token_hook from authenticated, anon, public; grant select on table public.tenant_users to supabase_auth_admin; create policy "auth admin reads tenant_users" on public.tenant_users for select to supabase_auth_admin using (true);
Then enable the hook in the dashboard, under Authentication > Hooks.
2. Turn on tenancy in .env:
SUPABASE_TENANCY_ENABLED=true SUPABASE_TENANT_CLAIM=tenant_id SUPABASE_TENANT_COLUMN=tenant_id
3. Use the trait. It needs the supabase guard.
use BlackDevs\SupabaseLaravel\Concerns\ScopesToTenant; class InvoiceRepository { use ScopesToTenant; public function all(): Collection { return $this->tenantTable('invoices')->get(); // adds tenant_id = <the user's tenant> } }
It throws RuntimeException if tenancy is off, nobody is signed in, or the claim is missing. The claim is read from app_metadata first, then from the top level of the token.
Vector search
Store and search pgvector embeddings. Searching needs a Postgres function that does the matching, such as this match_documents example:
create function match_documents(query_embedding vector(1536), match_threshold float, match_count int) returns table (id bigint, content text, similarity float) language sql stable as $$ select id, content, 1 - (embedding <=> query_embedding) as similarity from documents where 1 - (embedding <=> query_embedding) > match_threshold order by embedding <=> query_embedding limit match_count; $$;
Supabase::vector()->upsert('documents', ['id' => 1, 'content' => 'Hello'], 'embedding', $embedding); $matches = Supabase::vector()->search('match_documents', $queryEmbedding, 5, ['match_threshold' => 0.7]);
Test your app
Supabase::fake() fakes the responses, so your tests never reach a real project.
use BlackDevs\SupabaseLaravel\Facades\Supabase; use Illuminate\Support\Facades\Http; it('lists paid orders', function () { Supabase::fake([ 'rest/v1/orders*' => Http::response([['id' => 1, 'status' => 'paid']]), ]); $this->getJson('/api/orders')->assertOk()->assertJsonCount(1); Http::assertSent(fn ($request) => str_contains($request->url(), 'status=eq.paid')); });
The keys are checked before any request is made, so set dummy values in phpunit.xml. Otherwise your tests fail in CI with service_role_key is not set.
<env name="SUPABASE_URL" value="https://example.supabase.co"/> <env name="SUPABASE_ANON_KEY" value="test-anon-key"/> <env name="SUPABASE_SERVICE_ROLE_KEY" value="test-secret-key"/>
Logging and debugging
SUPABASE_LOG_ENABLED=true # method, URL, status and duration of each request SUPABASE_DEBUG=true # also logs request and response bodies
Both are off by default. password, access_token, refresh_token, apikey and authorization are masked in the logs, but other sensitive data in your rows is not, so do not leave debug mode on in production. Error responses are logged as warning. Requests and successful responses are logged as debug.
Configuration reference
| Variable | Default | Purpose |
|---|---|---|
SUPABASE_CONNECTION |
main |
The default connection |
SUPABASE_URL |
Project URL. Needed for everything | |
SUPABASE_ANON_KEY |
Publishable key. Needed for asUser(), anonymous(), auth() and functions() |
|
SUPABASE_SERVICE_ROLE_KEY |
Secret key. Needed for table(), rpc(), auth(), storage(), channel() and vector() |
|
SUPABASE_JWT_SECRET |
Needed for the supabase guard |
|
SUPABASE_DB_URL |
Postgres connection string for the SQL commands | |
SUPABASE_STORAGE_BUCKET |
Bucket name, if you use it in your disk config | |
SUPABASE_HTTP_TIMEOUT |
10 |
Request timeout in seconds |
SUPABASE_HTTP_RETRY_TIMES |
2 |
Total attempts for each request |
SUPABASE_HTTP_RETRY_SLEEP_MS |
200 |
Base wait between attempts. A Retry-After header takes priority |
SUPABASE_LOG_ENABLED |
false |
Log each request |
SUPABASE_LOG_CHANNEL |
App default | Log channel to use |
SUPABASE_DEBUG |
false |
Also log request and response bodies |
SUPABASE_TENANCY_ENABLED |
false |
Turn on tenant scoping |
SUPABASE_TENANT_CLAIM |
tenant_id |
JWT claim that holds the tenant ID |
SUPABASE_TENANT_COLUMN |
tenant_id |
Column to filter by |
SUPABASE_WEBHOOK_SECRET |
Shared secret for supabase.webhook |
|
SUPABASE_WEBHOOK_HEADER |
X-Supabase-Signature |
Header that carries the secret |
If your config is cached, run php artisan config:clear after changing .env.
Artisan commands
| Command | Purpose | Main options |
|---|---|---|
supabase:install |
Set up credentials interactively | --connection, --force |
supabase:test |
Check the APIs are reachable | --connection |
supabase:make-table {name} |
Create a migration file | --path |
supabase:migrate {table?} |
Run pending migrations | --connection, --path, --table, --dry-run |
supabase:rollback-table {name?} |
Undo migrations | --step, --connection, --dry-run |
supabase:make-function {name} |
Create a function file | --path, --force |
supabase:push-functions |
Apply changed function files | --connection, --path, --force, --dry-run |
supabase:rollback-function {function} |
Restore a function's previous version | --connection, --dry-run |
supabase:make-seeder {name} |
Create a seeder | --sql, --path |
supabase:seed {name?} |
Run seeders | --connection, --path, --dry-run |
supabase:schema-sync |
Generate typed classes. Not working yet | --connection, --path, --namespace, --only, --force |
Troubleshooting
Connecting over HTTPS
| Problem | Cause and fix |
|---|---|
Could not resolve host |
SUPABASE_URL is wrong, or the project is paused or removed. Check the project in the dashboard |
Supabase service_role_key is not set. |
The variable is missing from .env, or the config is cached. Set it, then run php artisan config:clear |
Invalid API key, but supabase:test passes |
supabase:test does not check keys. The key is wrong or empty. Copy it again |
Could not find the table |
The table does not exist, or the API has not noticed it yet. Run notify pgrst, 'reload schema'; in the SQL editor |
| A filter seems to be ignored | You used two filters on one column |
| Writes do nothing, and there is no error | You left out ->get() after insert(), update(), upsert() or delete() |
Rows are missing with asUser() or anonymous() |
RLS is hiding them. Check your policies |
Connecting directly to Postgres
| Problem | Cause and fix |
|---|---|
No db_url configured for the [main] connection |
Set SUPABASE_DB_URL |
could not find driver |
The pdo_pgsql extension is not enabled. On Ubuntu, install php8.4-pgsql |
Network is unreachable or a timeout on db.<ref>.supabase.co |
The direct host only supports IPv6. Use the session pooler |
tenant/user postgres.xxx not found |
The pooler host or username is wrong, or the project is paused or removed. Copy the string from Connect again |
password authentication failed |
Use the database password, not an API key, and encode special characters |
Invalid db_url |
The password has unencoded special characters. This error prints the whole URL, including the password, so clear it from any CI logs |
prepared statement ... does not exist |
Eloquent is using the transaction pooler. Use the session pooler, or emulate prepares |
supabase:migrate fails on a change that was already made |
The history file is missing or was not updated. Make the SQL safe to repeat, or restore the file |
Authentication
| Problem | Cause and fix |
|---|---|
Auth guard [supabase] is not defined. |
Add the guard to config/auth.php |
| 401 on every request, even with a valid token | The project uses asymmetric signing keys, or SUPABASE_JWT_SECRET is wrong. See the guard |
| A redirect instead of a 401 | The client must send Accept: application/json |
$request->user() is null, or every Gate is denied |
You used supabase.auth. Use auth:supabase |
Storage, cache and Realtime
| Problem | Cause and fix |
|---|---|
This driver does not support retrieving URLs. |
The disk has no url(). Use Supabase::storage()->getPublicUrl() |
| A signed URL returns 404 | Add /storage/v1 to the URL |
SupabaseStore contains 1 abstract method |
The cache store does not work on Laravel 13. Use another store |
| Private Realtime messages never arrive | Add a select policy on realtime.messages, and call setAuth() before subscribing |
License
MIT