Read Google Sheets Data in Laravel with the GKit SheetsAPI
Use Google Sheets as a live data layer in Laravel - Http facade, Eloquent-style collections, and optional Cache facade caching.
Google Sheets is a practical lightweight data store for small to medium datasets - your team can edit rows directly, there are no migrations to write, and the data is always visible. GKit SheetsAPI wraps your sheet in a REST endpoint so any backend can query it like a JSON API. Laravel's Http facade makes it trivial to build a clean, testable wrapper around that endpoint. This tutorial walks through a complete employee directory - service class, PHP 8.2 DTO, controller routes, and optional caching.
Sheet setup
Create a Google Sheet with the following columns in row 1, then connect it to GKit to get your user key and API token.
| name | department | role | start_date | |
|---|---|---|---|---|
| Alice Tan | Engineering | Senior Engineer | alice@example.com | 2022-03-15 |
| Bob Reyes | Marketing | Content Lead | bob@example.com | 2023-07-01 |
| Carol Müller | Engineering | Staff Engineer | carol@example.com | 2021-11-20 |
Name the sheet tab employees. GKit uses that tab name as the {sheetName} path segment.
The SheetsAPI contract
Every request follows the same shape:
GET https://api.gkit.io/api/spreadsheets/{userKey}/{sheetName}
Authorization: Bearer sk_...
Query parameters:
| Param | Example | Purpose |
|---|---|---|
search | department:Engineering | Filter by field value |
sort | start_date:desc | Sort by field |
limit | 20 | Page size |
offset | 40 | Skip N rows |
fields | name,email,role | Return only these columns |
The response is always:
{
"data": [...],
"meta": { "total": 42, "limit": 20, "offset": 0 }
}Store credentials in .env
Add two environment variables so the key is never hard-coded in source:
# .env
GKIT_API_KEY=sk_your_key_here
GKIT_USER_KEY=your_user_key_hereExpose them through config/services.php so they are accessible via the config() helper - the preferred Laravel pattern over calling env() directly in application code:
// config/services.php
return [
// ... other services
'gkit' => [
'api_key' => env('GKIT_API_KEY'),
'user_key' => env('GKIT_USER_KEY'),
'base_url' => 'https://api.gkit.io/api/spreadsheets',
],
];The SheetsApiService class
Create a service class that wraps the Http facade. All query-parameter forwarding lives here so controllers stay thin.
<?php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class SheetsApiService
{
private string $baseUrl;
private string $userKey;
private string $apiKey;
public function __construct()
{
$this->baseUrl = config('services.gkit.base_url');
$this->userKey = config('services.gkit.user_key');
$this->apiKey = config('services.gkit.api_key');
}
/**
* Query any sheet tab and return the decoded response array.
*
* @param string $sheetName Tab name in your Google Sheet
* @param array<string, mixed> $params search, sort, limit, offset, fields
* @return array{ data: array<int, array<string, mixed>>, meta: array<string, int> }
*/
public function query(string $sheetName, array $params = []): array
{
$response = Http::withToken($this->apiKey)
->get("{$this->baseUrl}/{$this->userKey}/{$sheetName}", array_filter($params));
$response->throw(); // bubbles as HttpClientException on 4xx/5xx
return $response->json();
}
}array_filter($params) removes any null values so only the params you explicitly pass are sent. withToken() sets the Authorization: Bearer ... header. throw() converts error responses into exceptions that Laravel's exception handler will catch and convert to a 500 response automatically.
Employee DTO
PHP 8.2 readonly classes make excellent value objects. They are immutable, self-documenting, and easy to test.
<?php
namespace App\Data;
readonly class Employee
{
public function __construct(
public string $name,
public string $department,
public string $role,
public string $email,
public string $startDate,
) {}
/**
* Construct an Employee from a raw SheetsAPI row array.
*
* @param array<string, mixed> $row
*/
public static function fromRow(array $row): self
{
return new self(
name: $row['name'] ?? '',
department: $row['department'] ?? '',
role: $row['role'] ?? '',
email: $row['email'] ?? '',
startDate: $row['start_date'] ?? '',
);
}
}Add a typed collection helper so callers always receive Collection<int, Employee> rather than plain arrays:
<?php
namespace App\Data;
use Illuminate\Support\Collection;
class EmployeeCollection
{
/**
* @param array<int, array<string, mixed>> $rows
* @return Collection<int, Employee>
*/
public static function fromRows(array $rows): Collection
{
return collect($rows)->map(fn (array $row) => Employee::fromRow($row));
}
}Controller
Register two routes in routes/api.php:
use App\Http\Controllers\EmployeeController;
Route::get('/employees', [EmployeeController::class, 'index']);
Route::get('/employees/{name}', [EmployeeController::class, 'show']);Then write the controller:
<?php
namespace App\Http\Controllers;
use App\Data\Employee;
use App\Data\EmployeeCollection;
use App\Services\SheetsApiService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EmployeeController extends Controller
{
public function __construct(private SheetsApiService $sheets) {}
/**
* GET /api/employees?department=Engineering
*/
public function index(Request $request): JsonResponse
{
$params = array_filter([
'search' => $request->query('department')
? "department:{$request->query('department')}"
: null,
'sort' => $request->query('sort', 'name:asc'),
'limit' => $request->query('limit', 50),
'offset' => $request->query('offset', 0),
]);
$response = $this->sheets->query('employees', $params);
$employees = EmployeeCollection::fromRows($response['data']);
return response()->json([
'data' => $employees->values(),
'meta' => $response['meta'],
]);
}
/**
* GET /api/employees/{name}
* Uses the search param to find a row by exact name match.
*/
public function show(string $name): JsonResponse
{
$response = $this->sheets->query('employees', [
'search' => "name:{$name}",
'limit' => 1,
]);
if (empty($response['data'])) {
return response()->json(['message' => 'Employee not found.'], 404);
}
return response()->json(Employee::fromRow($response['data'][0]));
}
}The ?department= query string is forwarded to search=department:Engineering - the controller does the translation so the API consumer never needs to know the SheetsAPI syntax.
Optional: Cache with Cache::remember()
SheetsAPI already handles rate limiting, but caching on the Laravel side is useful when the same sheet data is requested frequently and does not need to be real-time.
Add a thin caching layer inside SheetsApiService:
use Illuminate\Support\Facades\Cache;
public function queryCached(string $sheetName, array $params = [], int $ttl = 300): array
{
$cacheKey = "sheets:{$sheetName}:" . md5(serialize($params));
return Cache::remember($cacheKey, $ttl, fn () => $this->query($sheetName, $params));
}Cache::remember() returns the cached value if the key exists, or runs the closure, stores the result, and then returns it. The default TTL is 300 seconds (5 minutes). Swap $this->query(...) for $this->queryCached(...) in any controller action where staleness of a few minutes is acceptable:
$response = $this->sheets->queryCached('employees', $params, ttl: 300);Clear a specific sheet's cache when you know the data has changed:
Cache::forget("sheets:employees:" . md5(serialize($params)));Or flush all sheet caches with a tagged cache driver (Redis, Memcached) by tagging entries:
Cache::tags(['sheets', "sheets:{$sheetName}"])->remember($cacheKey, $ttl, fn () => ...);
Cache::tags("sheets:employees")->flush();What's next
- Validation - add
$request->validate(['department' => 'string|max:100'])before forwarding query parameters. - Pagination - the
meta.total,meta.limit, andmeta.offsetfields from SheetsAPI map directly to Laravel'sLengthAwarePaginatorif you want to return a standard paginated response. - Testing - use
Http::fake()to mock SheetsAPI responses in unit tests without hitting the network.
GKit SheetsAPI gives you a queryable REST layer over any Google Sheet in minutes. Combined with Laravel's Http facade and the Cache facade, you can build a maintainable, cache-friendly data layer with minimal boilerplate. Sign up at gkit.io to get your API key and connect your first sheet.
GKit is currently undergoing platform maintenance
Be the first to know when all Google Workspace tools are back online, plus get our engineering deep-dives and product announcements directly to your inbox.