Contract-driven TypeScript SDK for Laravel Query Gate
npm install laravel-query-gate-sdkA contract-driven TypeScript SDK for Laravel Query Gate that provides strongly-typed API interactions with compile-time safety.
- Contract-Driven: One contract per resource defines all operations
- Type-Safe: Full TypeScript support with compile-time validation
- Fluent API: Chainable, immutable builder pattern
- Laravel-Native Error Handling: Built-in support for all Laravel error responses
- Zero Runtime Overhead: Contracts exist only for TypeScript, no reflection
- Framework Agnostic: Works with any frontend framework or Node.js
``bash`
npm install laravel-query-gate-sdk
`bash`
yarn add laravel-query-gate-sdk
`bash`
pnpm add laravel-query-gate-sdk
`typescript
import { queryGate, configureQueryGate } from 'laravel-query-gate-sdk'
// 1. Configure the SDK
configureQueryGate({
baseUrl: 'https://api.example.com',
defaultHeaders: {
Authorization: 'Bearer your-token',
},
})
// 2. Define your contract
interface PostContract {
get: Post[]
create: CreatePostPayload
update: UpdatePostPayload
}
// 3. Use the SDK
const posts = await queryGate
.filter('status', 'eq', 'published')
.get()
`
- Configuration
- Defining Contracts
- Read Operations
- Write Operations
- Custom Actions
- Query Builder
- Versioning
- Headers & Options
- Error Handling
- API Reference
Configure the SDK once at application startup:
`typescript
import { configureQueryGate } from 'laravel-query-gate-sdk'
configureQueryGate({
baseUrl: 'https://api.example.com',
defaultHeaders: {
'Authorization': 'Bearer token',
'X-Tenant-ID': 'tenant-1',
},
defaultFetchOptions: {
credentials: 'include',
mode: 'cors',
},
})
`
For multi-tenant applications or testing, create isolated instances:
`typescript
import { createQueryGate } from 'laravel-query-gate-sdk'
const tenantApi = createQueryGate({
baseUrl: 'https://tenant1.api.example.com',
})
const posts = await tenantApi
`
| Option | Type | Description |
|--------|------|-------------|
| baseUrl | string | Required. Base URL for all API requests |defaultHeaders
| | Record | Headers included in every request |defaultFetchOptions
| | Partial | Default fetch options (credentials, mode, etc.) |
Contracts define the shape of your API resources. Each resource has one contract that describes:
- Read operations (get)create
- Write operations (, update)actions
- Custom actions ()
`typescript
import type { ResourceContract } from 'laravel-query-gate-sdk'
interface Post {
id: number
title: string
content: string
status: 'draft' | 'published'
created_at: string
}
interface CreatePostPayload {
title: string
content: string
}
interface UpdatePostPayload {
title?: string
content?: string
status?: 'draft' | 'published'
}
interface PostContract extends ResourceContract {
get: Post[]
create: CreatePostPayload
update: UpdatePostPayload
}
`
`typescript
interface PostContract extends ResourceContract {
get: Post[]
create: CreatePostPayload
update: UpdatePostPayload
actions: {
// Action without payload
publish: {
method: 'post'
payload?: never
response: Post
}
// Action with payload
bulkPublish: {
method: 'post'
payload: { ids: number[] }
response: { updated: number }
}
// GET action
stats: {
method: 'get'
payload?: never
response: { total: number; published: number }
}
// Search action
search: {
method: 'post'
payload: { query: string; filters?: Record
response: Post[]
}
}
}
`
If a resource only supports reading:
`typescript`
interface ReadOnlyPostContract extends ResourceContract {
get: Post[]
// No create or update - those methods will be hidden by TypeScript
}
`typescript`
const posts = await queryGate
// posts: Post[]
`typescript`
const post = await queryGate
// post: Post
`typescript`
const posts = await queryGate
.filter('status', 'eq', 'published')
.filter('author_id', 'eq', 1)
.sort('created_at', 'desc')
.get()
`typescript`
const newPost = await queryGate
title: 'My New Post',
content: 'Hello, world!',
})
// newPost: Post
`typescript`
const updatedPost = await queryGate
.id(1)
.patch({
title: 'Updated Title',
status: 'published',
})
// updatedPost: Post
`typescript`
await queryGate
Custom actions allow you to call non-CRUD endpoints defined in your Laravel Query Gate resource.
`typescript`
// POST /posts/1/publish
const publishedPost = await queryGate
.id(1)
.action('publish')
.post()
// publishedPost: Post
`typescript`
// POST /posts/bulk-publish
const result = await queryGate
.action('bulkPublish')
.post({ ids: [1, 2, 3, 4, 5] })
// result: { updated: number }
`typescript`
// GET /posts/stats
const stats = await queryGate
.action('stats')
.get()
// stats: { total: number; published: number }
TypeScript enforces correct usage:
`typescript
// Error: payload not allowed for 'publish' action
await queryGate
.id(1)
.action('publish')
.post({ foo: 'bar' }) // TypeScript error!
// Error: payload required for 'bulkPublish' action
await queryGate
.action('bulkPublish')
.post() // TypeScript error!
`
`typescript`
queryGate
.filter('status', 'eq', 'published')
.filter('views', 'gte', 100)
.filter('category_id', 'in', [1, 2, 3])
.get()
// Generates: ?filter[status][eq]=published&filter[views][gte]=100&filter[category_id][in]=1,2,3
Filtering on Relations:
`typescript`
queryGate
.filter('author.name', 'like', 'John')
.filter('category.is_active', 'eq', 1)
.get()
// Generates: ?filter[author.name][like]=John&filter[category.is_active][eq]=1
Available Operators:
| Operator | Description |
|----------|-------------|
| eq | Equal to |neq
| | Not equal to |gt
| | Greater than |gte
| | Greater or equal |lt
| | Less than |lte
| | Less or equal |in
| | In array |not_in
| | Not in array |like
| | Pattern match |between
| | Range (two values) |
`typescript`
queryGate
.sort('created_at', 'desc')
.sort('title', 'asc')
.get()
// Generates: ?sort=created_at:desc,title:asc
The SDK supports both Laravel pagination methods. The backend controls items per page.
#### Standard Pagination (Laravel paginate())
`typescript
import type { PaginateResponse } from 'laravel-query-gate-sdk'
// First page
const posts = await queryGate
.paginate()
.get() as PaginateResponse
// Specific page
const page2 = await queryGate
.paginate(2)
.get() as PaginateResponse
// Response includes: current_page, data, first_page_url, last_page, total, etc.
console.log(posts.data) // Post[]
console.log(posts.current_page) // 1
console.log(posts.last_page) // 10
console.log(posts.total) // 100
`
#### Cursor Pagination (Laravel cursorPaginate())
`typescript
import type { CursorPaginateResponse } from 'laravel-query-gate-sdk'
// First page
const posts = await queryGate
.cursor()
.get() as CursorPaginateResponse
// URL: /posts
// Next page
const nextPage = await queryGate
.cursor(posts.next_cursor)
.get() as CursorPaginateResponse
// URL: /posts?cursor=eyJjcmVhdGVkX2F0Ijoi...
// Response includes: data, next_cursor, prev_cursor, next_page_url, prev_page_url
console.log(posts.data) // Post[]
console.log(posts.next_cursor) // "eyJjcmVhdGVkX2F0Ijoi..."
`
> Note: With cursor pagination, filters and sorts are not sent in the URL. The backend controls the query, and the cursor handles navigation.
#### No Pagination (Fetch All)
`typescript`
// Without .paginate() or .cursor(), fetches all records
const allPosts = await queryGate
// allPosts: Post[]
Laravel Query Gate supports API versioning via the X-Query-Version header:
`typescript`
const posts = await queryGate
.version('2.0')
.get()
`typescript`
queryGate
.header('X-Custom-Header', 'value')
.get()
`typescript`
queryGate
.headers({
'X-Custom-Header': 'value',
'X-Another-Header': 'another-value',
})
.get()
`typescript
const controller = new AbortController()
queryGate
.options({
signal: controller.signal,
credentials: 'include',
mode: 'cors',
cache: 'no-cache',
})
.get()
`
The SDK provides specific error classes for all common Laravel error responses.
| Status | Error Class | Laravel Context |
|--------|-------------|-----------------|
| 401 | QueryGateUnauthorizedError | Auth middleware |QueryGateForbiddenError
| 403 | | Policy/Gate denial |QueryGateNotFoundError
| 404 | | Model not found |QueryGateCsrfMismatchError
| 419 | | CSRF token invalid/expired |QueryGateValidationError
| 422 | | Form Request validation |QueryGateRateLimitError
| 429 | | Throttle middleware |QueryGateServerError
| 500 | | Internal server error |QueryGateServiceUnavailableError
| 503 | | Maintenance mode |QueryGateNetworkError
| - | | Connection failed |QueryGateHttpError
| - | | Other HTTP errors |
`typescript`
import {
isUnauthorizedError,
isForbiddenError,
isNotFoundError,
isCsrfMismatchError,
isValidationError,
isRateLimitError,
isServerError,
isServiceUnavailableError,
isNetworkError,
isHttpError,
} from 'laravel-query-gate-sdk'
`typescript
import {
queryGate,
isUnauthorizedError,
isForbiddenError,
isNotFoundError,
isCsrfMismatchError,
isValidationError,
isRateLimitError,
isServerError,
isServiceUnavailableError,
isNetworkError,
} from 'laravel-query-gate-sdk'
try {
await queryGate
title: 'New Post',
content: 'Content here',
})
} catch (error) {
// 401 - Unauthorized
if (isUnauthorizedError(error)) {
console.error('Please log in')
redirectToLogin()
return
}
// 403 - Forbidden
if (isForbiddenError(error)) {
console.error('You do not have permission')
return
}
// 404 - Not Found
if (isNotFoundError(error)) {
console.error('Resource not found')
return
}
// 419 - CSRF Token Mismatch
if (isCsrfMismatchError(error)) {
console.error('Session expired. Please refresh.')
refreshCsrfToken()
return
}
// 422 - Validation Error
if (isValidationError(error)) {
console.error('Validation failed:', error.originalMessage)
// Access all errors
console.error('Errors:', error.errors)
// { title: ['The title field is required.'], ... }
// Check specific field
if (error.hasFieldError('title')) {
console.error('Title error:', error.getFirstFieldError('title'))
}
// Get all fields with errors
console.error('Fields with errors:', error.getErrorFields())
return
}
// 429 - Rate Limit
if (isRateLimitError(error)) {
console.error('Too many requests')
if (error.hasRetryAfter()) {
console.error(Retry after ${error.retryAfter} seconds)Retry at: ${retryDate?.toLocaleTimeString()}
const retryDate = error.getRetryDate()
console.error()
}
return
}
// 500 - Server Error
if (isServerError(error)) {
console.error('Server error. Please try again later.')
reportToErrorTracking(error)
return
}
// 503 - Service Unavailable (Maintenance)
if (isServiceUnavailableError(error)) {
console.error('Service temporarily unavailable')
if (error.hasRetryAfter()) {
console.error(Back in approximately ${error.retryAfter} seconds)
}
return
}
// Network Error
if (isNetworkError(error)) {
console.error('Network error:', error.originalError.message)
console.error('Please check your internet connection')
return
}
// Unknown error
throw error
}
`
The QueryGateValidationError provides helper methods for working with Laravel's validation error format:
`typescript
if (isValidationError(error)) {
// Get all errors for a field
const titleErrors: string[] = error.getFieldErrors('title')
// ['The title field is required.', 'The title must be at least 3 characters.']
// Get first error for a field
const firstError: string | undefined = error.getFirstFieldError('title')
// 'The title field is required.'
// Check if field has errors
const hasError: boolean = error.hasFieldError('title')
// true
// Get all fields with errors
const fields: string[] = error.getErrorFields()
// ['title', 'content', 'author_id']
// Access raw errors object
const rawErrors: Record
// { title: [...], content: [...] }
// Access original Laravel message
const message: string = error.originalMessage
// 'The given data was invalid.'
}
`
Both QueryGateRateLimitError (429) and QueryGateServiceUnavailableError (503) support the Retry-After header:
`typescript
if (isRateLimitError(error) || isServiceUnavailableError(error)) {
// Check if retry information is available
if (error.hasRetryAfter()) {
// Get seconds until retry
const seconds: number | null = error.retryAfter
// 60
// Get Date object for when retry is allowed
const retryDate: Date | null = error.getRetryDate()
// Date object
}
}
`
#### configureQueryGate(config: QueryGateConfig): void
Configure the SDK globally.
#### queryGate
Create a builder for a resource using global configuration.
#### createQueryGate(config: QueryGateConfig):
Create an isolated SDK instance with its own configuration.
| Method | Description |
|--------|-------------|
| .id(id) | Set resource ID for single resource operations |.filter(field, operator, value)
| | Add a filter |.sort(field, direction?)
| | Add sorting (default: 'asc') |.paginate(page?)
| | Use standard pagination (Laravel paginate()) |.cursor(cursor?)
| | Use cursor pagination (Laravel cursorPaginate()) |.version(version)
| | Set API version header |.header(key, value)
| | Add a single header |.headers(record)
| | Add multiple headers |.options(fetchOptions)
| | Set fetch options |.get()
| | Execute GET request |.post(payload)
| | Execute POST request |.action(name)
| | Access custom action |
Available after calling .id():
| Method | Description |
|--------|-------------|
| .get() | Fetch single resource |.patch(payload)
| | Update resource |.delete()
| | Delete resource |.action(name)
| | Execute action on resource |
`typescript``
import type {
ResourceContract,
ActionContract,
FilterOperator,
SortDirection,
QueryGateConfig,
ValidationErrors,
// Pagination response types
PaginateResponse,
CursorPaginateResponse,
} from 'laravel-query-gate-sdk'
The SDK follows these principles:
1. Explicit contracts over magic - All behavior is declared in contracts
2. Compile-time safety over runtime guessing - TypeScript catches errors before runtime
3. Orchestration over domain logic - SDK is a transport layer only
4. Consistency over brevity - Predictable API surface
- Node.js 18+
- TypeScript 5.0+ (recommended)
MIT
Contributions are welcome! Please read the contributing guidelines before submitting a PR.
- Laravel Query Gate - The Laravel package this SDK is built for