npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

laravel-query-gate-sdk

v1.0.3

Published

Contract-driven TypeScript SDK for Laravel Query Gate

Readme

Laravel Query Gate SDK

A contract-driven TypeScript SDK for Laravel Query Gate that provides strongly-typed API interactions with compile-time safety.

Features

  • 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

Installation

npm install laravel-query-gate-sdk
yarn add laravel-query-gate-sdk
pnpm add laravel-query-gate-sdk

Quick Start

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<PostContract>('posts')
  .filter('status', 'eq', 'published')
  .get()

Table of Contents

Configuration

Global Configuration

Configure the SDK once at application startup:

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',
  },
})

Isolated Instances

For multi-tenant applications or testing, create isolated instances:

import { createQueryGate } from 'laravel-query-gate-sdk'

const tenantApi = createQueryGate({
  baseUrl: 'https://tenant1.api.example.com',
})

const posts = await tenantApi<PostContract>('posts').get()

Configuration Options

| Option | Type | Description | |--------|------|-------------| | baseUrl | string | Required. Base URL for all API requests | | defaultHeaders | Record<string, string> | Headers included in every request | | defaultFetchOptions | Partial<RequestInit> | Default fetch options (credentials, mode, etc.) |

Defining Contracts

Contracts define the shape of your API resources. Each resource has one contract that describes:

  • Read operations (get)
  • Write operations (create, update)
  • Custom actions (actions)

Basic Contract

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
}

Contract with Custom Actions

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<string, unknown> }
      response: Post[]
    }
  }
}

Read-Only Contract

If a resource only supports reading:

interface ReadOnlyPostContract extends ResourceContract {
  get: Post[]
  // No create or update - those methods will be hidden by TypeScript
}

Read Operations

Fetch Collection

const posts = await queryGate<PostContract>('posts').get()
// posts: Post[]

Fetch Single Resource

const post = await queryGate<PostContract>('posts').id(1).get()
// post: Post

With Query Parameters

const posts = await queryGate<PostContract>('posts')
  .filter('status', 'eq', 'published')
  .filter('author_id', 'eq', 1)
  .sort('created_at', 'desc')
  .get()

Write Operations

Create (POST)

const newPost = await queryGate<PostContract>('posts').post({
  title: 'My New Post',
  content: 'Hello, world!',
})
// newPost: Post

Update (PATCH)

const updatedPost = await queryGate<PostContract>('posts')
  .id(1)
  .patch({
    title: 'Updated Title',
    status: 'published',
  })
// updatedPost: Post

Delete

await queryGate<PostContract>('posts').id(1).delete()

Custom Actions

Custom actions allow you to call non-CRUD endpoints defined in your Laravel Query Gate resource.

Action Without Payload

// POST /posts/1/publish
const publishedPost = await queryGate<PostContract>('posts')
  .id(1)
  .action('publish')
  .post()
// publishedPost: Post

Action With Payload

// POST /posts/bulk-publish
const result = await queryGate<PostContract>('posts')
  .action('bulkPublish')
  .post({ ids: [1, 2, 3, 4, 5] })
// result: { updated: number }

GET Action

// GET /posts/stats
const stats = await queryGate<PostContract>('posts')
  .action('stats')
  .get()
// stats: { total: number; published: number }

Type Safety

TypeScript enforces correct usage:

// Error: payload not allowed for 'publish' action
await queryGate<PostContract>('posts')
  .id(1)
  .action('publish')
  .post({ foo: 'bar' }) // TypeScript error!

// Error: payload required for 'bulkPublish' action
await queryGate<PostContract>('posts')
  .action('bulkPublish')
  .post() // TypeScript error!

Query Builder

Filters

queryGate<PostContract>('posts')
  .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:

queryGate<PostContract>('posts')
  .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) |

Sorting

queryGate<PostContract>('posts')
  .sort('created_at', 'desc')
  .sort('title', 'asc')
  .get()
// Generates: ?sort=created_at:desc,title:asc

Pagination

The SDK supports both Laravel pagination methods. The backend controls items per page.

Standard Pagination (Laravel paginate())

import type { PaginateResponse } from 'laravel-query-gate-sdk'

// First page
const posts = await queryGate<PostContract>('posts')
  .paginate()
  .get() as PaginateResponse<Post>

// Specific page
const page2 = await queryGate<PostContract>('posts')
  .paginate(2)
  .get() as PaginateResponse<Post>

// 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())

import type { CursorPaginateResponse } from 'laravel-query-gate-sdk'

// First page
const posts = await queryGate<PostContract>('posts')
  .cursor()
  .get() as CursorPaginateResponse<Post>
// URL: /posts

// Next page
const nextPage = await queryGate<PostContract>('posts')
  .cursor(posts.next_cursor)
  .get() as CursorPaginateResponse<Post>
// 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)

// Without .paginate() or .cursor(), fetches all records
const allPosts = await queryGate<PostContract>('posts').get()
// allPosts: Post[]

Versioning

Laravel Query Gate supports API versioning via the X-Query-Version header:

const posts = await queryGate<PostContract>('posts')
  .version('2.0')
  .get()

Headers & Options

Single Header

queryGate<PostContract>('posts')
  .header('X-Custom-Header', 'value')
  .get()

Multiple Headers

queryGate<PostContract>('posts')
  .headers({
    'X-Custom-Header': 'value',
    'X-Another-Header': 'another-value',
  })
  .get()

Fetch Options

const controller = new AbortController()

queryGate<PostContract>('posts')
  .options({
    signal: controller.signal,
    credentials: 'include',
    mode: 'cors',
    cache: 'no-cache',
  })
  .get()

Error Handling

The SDK provides specific error classes for all common Laravel error responses.

Error Types

| Status | Error Class | Laravel Context | |--------|-------------|-----------------| | 401 | QueryGateUnauthorizedError | Auth middleware | | 403 | QueryGateForbiddenError | Policy/Gate denial | | 404 | QueryGateNotFoundError | Model not found | | 419 | QueryGateCsrfMismatchError | CSRF token invalid/expired | | 422 | QueryGateValidationError | Form Request validation | | 429 | QueryGateRateLimitError | Throttle middleware | | 500 | QueryGateServerError | Internal server error | | 503 | QueryGateServiceUnavailableError | Maintenance mode | | - | QueryGateNetworkError | Connection failed | | - | QueryGateHttpError | Other HTTP errors |

Type Guards

import {
  isUnauthorizedError,
  isForbiddenError,
  isNotFoundError,
  isCsrfMismatchError,
  isValidationError,
  isRateLimitError,
  isServerError,
  isServiceUnavailableError,
  isNetworkError,
  isHttpError,
} from 'laravel-query-gate-sdk'

Complete Error Handling Example

import {
  queryGate,
  isUnauthorizedError,
  isForbiddenError,
  isNotFoundError,
  isCsrfMismatchError,
  isValidationError,
  isRateLimitError,
  isServerError,
  isServiceUnavailableError,
  isNetworkError,
} from 'laravel-query-gate-sdk'

try {
  await queryGate<PostContract>('posts').post({
    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`)
      const retryDate = error.getRetryDate()
      console.error(`Retry at: ${retryDate?.toLocaleTimeString()}`)
    }
    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
}

Validation Error Details

The QueryGateValidationError provides helper methods for working with Laravel's validation error format:

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<string, string[]> = error.errors
  // { title: [...], content: [...] }

  // Access original Laravel message
  const message: string = error.originalMessage
  // 'The given data was invalid.'
}

Rate Limit & Service Unavailable Retry-After

Both QueryGateRateLimitError (429) and QueryGateServiceUnavailableError (503) support the Retry-After header:

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
  }
}

API Reference

Entry Points

configureQueryGate(config: QueryGateConfig): void

Configure the SDK globally.

queryGate<T>(resource: string): QueryGateBuilder<T>

Create a builder for a resource using global configuration.

createQueryGate(config: QueryGateConfig): <T>(resource: string) => QueryGateBuilder<T>

Create an isolated SDK instance with its own configuration.

QueryGateBuilder Methods

| 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 |

QueryGateBuilderWithId Methods

Available after calling .id():

| Method | Description | |--------|-------------| | .get() | Fetch single resource | | .patch(payload) | Update resource | | .delete() | Delete resource | | .action(name) | Execute action on resource |

Types

import type {
  ResourceContract,
  ActionContract,
  FilterOperator,
  SortDirection,
  QueryGateConfig,
  ValidationErrors,
  // Pagination response types
  PaginateResponse,
  CursorPaginateResponse,
} from 'laravel-query-gate-sdk'

Design Philosophy

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

Requirements

  • Node.js 18+
  • TypeScript 5.0+ (recommended)

License

MIT

Contributing

Contributions are welcome! Please read the contributing guidelines before submitting a PR.

Related