Google Sheets API in PHP & Laravel without OAuth - SheetDB

How to read and write Google Sheets with PHP

The official way to use Google Sheets from PHP is the google/apiclient package: create a Google Cloud project, enable the Sheets API, create a service account, download its JSON key, share the spreadsheet with it, then work with A1 ranges and nested arrays of cell values. It works, but it is a lot of setup for "read some rows and add a new one".

This guide uses SheetDB instead. It turns a spreadsheet into a JSON REST API, so rows arrive as associative arrays keyed by the column names in the first row, and every operation is a single HTTP request. That means you can use whatever HTTP tool your project already has: plain file_get_contents(), the cURL extension, the small SheetDB PHP library, Guzzle or Laravel's Http client. No OAuth, no Google Cloud project and no service account.

Quick start: save this as sheets.php and run php sheets.php. It reads a real spreadsheet right now.

<?php
// Any PHP version, no Composer, no extensions beyond the defaults.
$rows = json_decode(file_get_contents('https://sheetdb.io/api/v1/58f61be4dda40'), true);

foreach ($rows as $row) {
    echo "{$row['name']} is {$row['age']} years old\n";
}
// Tom is 15 years old
// Alex is 24 years old
// John is 51 years old
// Steve is 22 years old
// James is 19 years old

All examples use this demo spreadsheet, available as the API https://sheetdb.io/api/v1/58f61be4dda40. Its first tab has the columns id, name, age and comment, and a second tab, Sheet2, has player, score and comment. The demo resets every 15 minutes, so feel free to run the write examples.

Step 1: create an API for your spreadsheet

  1. Put column names in the first row of the sheet (id, name, email...). They become the array keys in PHP, so short lowercase names without spaces are the most convenient.
  2. Sign in to SheetDB with the Google account that can edit the spreadsheet, click Create new API and paste the spreadsheet URL.
  3. Copy the API URL, https://sheetdb.io/api/v1/<your id>, into your configuration, for example a SHEETDB_URL entry in .env. The spreadsheet stays private; SheetDB reads and writes it with your Google account.

Step 2: a reusable cURL helper

The examples below share one function built on the cURL extension, which is available in almost every PHP installation. It sends and receives JSON, returns the decoded response as an array and throws an exception on error statuses, so a failed request never looks like an empty result.

<?php
// sheetdb.php – one function for every request, using the cURL extension
function sheetdb(string $path = '', string $method = 'GET', ?array $body = null): array
{
    $url = getenv('SHEETDB_URL') ?: 'https://sheetdb.io/api/v1/58f61be4dda40';

    $curl = curl_init($url . $path);
    curl_setopt_array($curl, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json'],
    ]);

    if ($body !== null) {
        curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($body));
    }

    $raw = curl_exec($curl);
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);

    if ($raw === false) {
        throw new RuntimeException('SheetDB request failed: ' . curl_error($curl));
    }

    $payload = json_decode($raw, true) ?? [];

    if ($status >= 400) {
        throw new RuntimeException("SheetDB {$status}: " . ($payload['error'] ?? 'request failed'), $status);
    }

    return $payload;
}

Read rows from Google Sheets in PHP

A GET on the API URL returns all rows of the first tab. Use query parameters to let the API sort and trim the result before it reaches PHP:

<?php
require 'sheetdb.php';

// All rows of the first tab
$people = sheetdb();

// Two oldest people, sorted and limited by the API
$oldest = sheetdb('?sort_by=age&sort_order=desc&limit=2');
// John (51), Alex (24)

// Pagination: skip 2 rows, return the next 2
$page2 = sheetdb('?limit=2&offset=2');

// Real numbers instead of strings for the listed columns
$typed = sheetdb('?cast_numbers=age&limit=1');
// [['id' => '1', 'name' => 'Tom', 'age' => 15, 'comment' => '']]

// Another tab of the same spreadsheet
$players = sheetdb('?sheet=Sheet2');
// [['player' => 'Smith', 'score' => '41', 'comment' => ''], ...]
  • limit and offset paginate.
  • sort_by and sort_order (asc, desc or random) sort.
  • sheet selects a tab by name; the first tab is the default.
  • cast_numbers returns the listed columns as numbers; otherwise every value is a string.

Reference: read docs.

Search rows

/search returns rows that match every condition and /search_or rows that match at least one. Each condition is a column=value query parameter; values accept * wildcards, ! for negation and >, <, >=, <= comparisons. Matching ignores case unless you add casesensitive=true.

<?php
require 'sheetdb.php';

// AND: every condition must match
$special = sheetdb('/search?' . http_build_query(['age' => '>18', 'comment' => 'special']));
// [['id' => '4', 'name' => 'Steve', 'age' => '22', 'comment' => 'special']]

// OR: any condition may match
$either = sheetdb('/search_or?' . http_build_query(['age' => '<16', 'comment' => 'special']));
// Tom (15) and Steve ("special")

// Wildcard, and one row instead of a list
$steve = sheetdb('/search?name=Ste*&single_object=true');
// ['id' => '4', 'name' => 'Steve', 'age' => '22', 'comment' => 'special']

Build the query string with http_build_query() so user input is encoded properly. No match gives an empty array, not an error. Reference: search docs.

Insert, update and delete rows

Writes use the same helper with a different HTTP method. A POST appends rows to the bottom of the tab. PATCH and DELETE take the rows to change from the path, /{column}/{value}, so /id/5 means "where id equals 5".

<?php
require 'sheetdb.php';

// INSERT: one or many rows in a single request
sheetdb('', 'POST', ['data' => [
    ['id' => 'INCREMENT', 'name' => 'Mark', 'age' => 35, 'comment' => 'new customer'],
    ['id' => 'INCREMENT', 'name' => 'Anna', 'age' => 28, 'comment' => 'DATETIME'],
]]);
// ['created' => 2]

// UPDATE ... SET age = 36 WHERE name = 'Mark'
sheetdb('/name/' . rawurlencode('Mark'), 'PATCH', ['data' => ['age' => 36]]);
// ['updated' => 1]

// DELETE ... WHERE name = 'Mark'
sheetdb('/name/' . rawurlencode('Mark'), 'DELETE');
// ['deleted' => 1]
  • Array keys must match the column names exactly; keys that match no column are ignored.
  • INCREMENT fills in the next number, DATETIME the current date and time and TIMESTAMP a Unix timestamp.
  • An update changes only the columns you send, in every row that matches. Update by a unique column such as id when you mean one row. If nothing matches, the API returns 404 with {"error": "No results found"}.
  • A delete removes every matching row and the rows below move up. Be careful with wildcards: /name/* matches every row with a name.
  • Add ?sheet=Sheet2 to the path to write to another tab.

References: create, update (including batch_update for many different rows in one request) and delete.

Using the SheetDB PHP library

If you would rather call methods than build URLs, the sheetdb/sheetdb-php package wraps the same endpoints. It has no dependencies and works with any PHP version since 5.5. Install it with composer require sheetdb/sheetdb-php:

<?php
require 'vendor/autoload.php';

use SheetDB\SheetDB;

$sheetdb = new SheetDB('58f61be4dda40');           // first tab
$players = new SheetDB('58f61be4dda40', 'Sheet2'); // a named tab

// Read: arrays of stdClass objects, or null on failure
$rows = $sheetdb->get();
if (!$rows) {
    // null (request failed) or [] (no rows)
}
echo $rows[0]->name; // Tom

$columns = $sheetdb->keys();                                // ['id', 'name', 'age', 'comment']
$steve = $sheetdb->search(['name' => 'Steve', 'age' => '22']);   // AND
$some = $sheetdb->searchOr(['name' => 'Steve', 'age' => '19']);  // OR: Steve and James

// Write: each call returns the number of affected rows, or false
$sheetdb->create(['id' => 'INCREMENT', 'name' => 'Mark', 'age' => '35']);
$sheetdb->create([
    ['id' => 'INCREMENT', 'name' => 'Chris', 'age' => '34'],
    ['id' => 'INCREMENT', 'name' => 'Amanda', 'age' => '29'],
]);
$sheetdb->update('name', 'Chris', ['age' => '33']);
$sheetdb->delete('name', 'Chris');

Note the return types: reads give you stdClass objects ($row->name, not $row['name']), and return null when the request fails; writes return the number of affected rows, or false on failure. Check every result: if (!$rows) catches both a failed read and an empty result, and if ($count === false) a failed write. The library does not send authentication headers; for an API protected with Basic Auth or a Bearer token use the cURL helper or Laravel's client.

Google Sheets in Laravel

Laravel needs no extra package: the Http client covers everything. A small service class keeps the URL in configuration, caches reads, retries reads that failed for a temporary reason (network error, 429 or 5xx) and clears the cache after a write:

<?php
// config/services.php
'sheetdb' => [
    'url' => env('SHEETDB_URL'),
    'token' => env('SHEETDB_TOKEN'),
],

// app/Services/PeopleSheet.php
namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Throwable;

class PeopleSheet
{
    /** @return list<array{id: string, name: string, age: string, comment: string}> */
    public function all(): array
    {
        return Cache::remember('people-sheet', now()->addMinutes(5), fn (): array => $this->client()
            ->retry(3, 1000, fn (Throwable $exception): bool => $this->isTemporary($exception), throw: false)
            ->get(config('services.sheetdb.url'))
            ->throw()
            ->json());
    }

    /** @param array{name: string, age: int} $person */
    public function add(array $person): void
    {
        // No retry: a POST that timed out may still have added the row.
        $this->client()
            ->post(config('services.sheetdb.url'), ['data' => [['id' => 'INCREMENT', ...$person]]])
            ->throw();

        Cache::forget('people-sheet');
    }

    private function client(): PendingRequest
    {
        return Http::acceptJson()
            ->when(config('services.sheetdb.token'), fn (PendingRequest $request, string $token) => $request->withToken($token));
    }

    /** Retry only network errors, rate limits (429) and server errors (5xx). */
    private function isTemporary(Throwable $exception): bool
    {
        return $exception instanceof ConnectionException
            || ($exception instanceof RequestException
                && ($exception->response->status() === 429 || $exception->response->serverError()));
    }
}

throw() turns 4xx and 5xx responses into a RequestException, so errors reach your exception handler instead of becoming empty arrays. Inject PeopleSheet into a controller or a Livewire component and the sheet behaves like any other data source. In tests, Http::fake() replaces the API with canned responses. The same code works in Symfony or Slim with Guzzle or Symfony HttpClient, since it is plain JSON over HTTPS.

PHP without cURL or Composer

On shared hosting without the cURL extension, file_get_contents() with a stream context does the same job. Set ignore_errors: without it PHP drops the response body of any 4xx or 5xx status and returns false, and you never see the error message.

<?php
function sheetdbRequest(string $url, string $method = 'GET', ?array $body = null): array
{
    $context = stream_context_create(['http' => [
        'method' => $method,
        'header' => "Accept: application/json\r\nContent-Type: application/json\r\n",
        'content' => $body === null ? '' : json_encode($body),
        'ignore_errors' => true, // keep the JSON body of 4xx/5xx responses
    ]]);

    $raw = file_get_contents($url, false, $context);
    $payload = json_decode((string) $raw, true) ?? [];

    // $http_response_header[0] is the status line, e.g. "HTTP/1.1 429 Too Many Requests"
    preg_match('/\s(\d{3})\s/', $http_response_header[0] ?? '', $match);

    if ((int) ($match[1] ?? 0) >= 400) {
        throw new RuntimeException($payload['error'] ?? 'SheetDB request failed');
    }

    return $payload;
}

$rows = sheetdbRequest('https://sheetdb.io/api/v1/58f61be4dda40/search?name=Steve');

Error handling, rate limits and authentication

Errors come back as JSON with an error key and a matching status: 400 for a request without valid data, 401 for missing or wrong credentials, 403 when the operation is disabled in the API permissions, 404 for a wrong API id, a missing tab or no matching rows, and 429 when there are too many requests to your API or to the spreadsheet on Google's side. Retry 429 and 5xx responses after a short, growing delay; fix the request for the other 4xx codes. The errors docs list the messages.

Requests are cheaper when you make fewer of them. SheetDB caches read responses for 15 seconds by default (adjustable in the API settings, and cleared by every write made through the API), and caching in your own app for a few minutes, as in the Laravel example, removes most reads entirely. Insert many rows in one POST rather than in a loop. Current numbers are on the limits page.

A new API is open to anyone who knows its URL. Because PHP runs on your server, protecting it is easy: enable HTTP Basic Auth or a Bearer token in the API settings and send the credentials with each request.

// cURL with a Bearer token (API settings → Bearer token)
CURLOPT_HTTPHEADER => [
    'Accept: application/json',
    'Content-Type: application/json',
    'Authorization: Bearer ' . getenv('SHEETDB_TOKEN'),
],

// cURL with HTTP Basic Auth (API settings → HTTP Basic Auth)
CURLOPT_USERPWD => getenv('SHEETDB_LOGIN') . ':' . getenv('SHEETDB_PASSWORD'),

// Laravel
Http::withToken(config('services.sheetdb.token'))->get($url);
Http::withBasicAuth($login, $password)->get($url);

In the same settings, switch off the permissions your application does not use, such as Delete (DELETE). More in the authentication docs.

Limitations

  • Not for heavy write traffic. Google throttles how often a spreadsheet can be written and read. Reads are cached; thousands of writes per minute belong in MySQL or PostgreSQL.
  • Large tabs are slower. Google returns the whole tab before SheetDB filters it, so keep tabs lean and archive old rows.
  • No transactions or joins. Concurrent updates to the same row are last-write-wins, and relations between tabs are up to your code.
  • Strings by default. Cast numbers with cast_numbers or in PHP, and keep dates in one format in the sheet.

FAQ

Do I need the google/apiclient package or a Google Cloud project to use Google Sheets in PHP?

No. Google's official PHP client needs a Google Cloud project, the Sheets API enabled, a service account or OAuth credentials and the spreadsheet shared with that account. SheetDB handles the Google side: you sign in once, paste the spreadsheet URL and get an HTTPS endpoint that returns JSON. Any PHP code that can make an HTTP request, from file_get_contents() to Guzzle, can read and write the sheet.

How do I use Google Sheets in Laravel?

Use the built-in Http client: Http::get($url)->json() reads rows and Http::post($url, ['data' => [...]]) adds them. Store the API URL and credentials in config/services.php backed by .env, wrap reads in Cache::remember() if the data changes rarely, and use withBasicAuth() or withToken() when the API is protected. No extra Composer package is needed.

Why does file_get_contents() return false when the API answers with an error?

By default PHP's HTTP stream wrapper treats any 4xx or 5xx status as a failure and discards the body. Add 'ignore_errors' => true to the http context options, as the helper on this page does, and you get the JSON body, for example {"error": "Too many requests"}, plus the status line in $http_response_header.

Can I save a PHP contact form to Google Sheets?

Yes. Validate the input as usual and POST it to the API with the column names as keys; the row appears at the bottom of the sheet. Because the request is made by your server, the API URL and credentials never reach the browser. If you do not have a backend at all, an HTML form can post to Google Sheets directly.

How do I protect the API that my PHP app uses?

In the SheetDB API settings enable HTTP Basic Auth or a Bearer token, and switch off the permissions your app does not use (for example Delete (DELETE)). Then send the credentials with every request: a CURLOPT_USERPWD or Authorization header in cURL, or withBasicAuth() / withToken() in Laravel. The SheetDB PHP library does not send credentials, so use one of the other methods for a protected API. Details are in the authentication docs.

Is the SheetDB API free for PHP projects?

There is a free plan with 2 APIs and 500 requests a month, no credit card required. Every HTTP call counts as one request, so insert many rows in one POST and cache reads in your application (for example with Laravel's cache or APCu) to stay within the quota.

Want to use the sheet as a database?

The requests above are all you need to treat a spreadsheet as the database behind a PHP application. See how to use Google Sheets as a database for sheet structure tips and the limits to keep in mind. The same API works from Node.js, from a plain HTML form and in WordPress without any PHP at all.

Connect your spreadsheet to PHP

Sign in with Google, paste the spreadsheet URL and read it with one HTTP request. Free plan, no credit card, no Google Cloud setup.

Create free API

Have a question?

Ask about the API or your sheet. Developers answer, usually the same day.

Necessary cookies keep SheetDB working. With your permission we also use chat, analytics and marketing cookies. You can change your choice anytime in Cookie settings. Cookies Policy