REST APIs, GraphQL, cURL & Swagger
Designing REST APIs properly (resources, verbs, status codes, pagination, versioning), when GraphQL is a better fit, testing any API from the terminal with cURL, and documenting it with OpenAPI/Swagger, all with e-commerce, banking and payment endpoints in Laravel, Spring Boot and React.
Running examples: product catalogue, order placement, payment refunds, account statements
1. REST: resources, verbs and status codes
REST (Representational State Transfer) models an API as resources (nouns) addressed by URLs, manipulated with standard HTTP methods (verbs). The server is stateless: each request carries everything needed, including authentication. Responses are usually JSON.
Good REST design makes an API predictable: once a developer knows GET /orders, they can guess GET /orders/{id}, POST /orders and so on.
| Method + URL | Meaning | Success code | Idempotent? |
|---|---|---|---|
| GET /api/v1/products?category=mobiles&sort=-price&page=2 | List with filter, sort, pagination | 200 | Yes |
| GET /api/v1/orders/ORD-187 | One order | 200 / 404 | Yes |
| POST /api/v1/orders | Place an order | 201 + Location header | No (use Idempotency-Key) |
| PATCH /api/v1/orders/ORD-187 | Change delivery address | 200 | Usually |
| POST /api/v1/orders/ORD-187/cancel | Domain action (not CRUD) | 200 / 409 if shipped | Should be |
| DELETE /api/v1/cart/items/55 | Remove from cart | 204 | Yes |
Rules of thumb: plural nouns, no verbs in URLs except for domain actions, nested URLs only one level deep (/orders/187/items), version in the URL or a header (/v1), consistent JSON envelope, and never return a 200 with "error" in the body.
2. Laravel REST endpoint with resources and pagination
Controller, API Resource and cursor pagination
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
Route::apiResource('orders', OrderController::class)->only(['index', 'show', 'store']);
Route::post('orders/{order}/cancel', CancelOrderController::class);
});
class OrderController extends Controller
{
public function index(Request $request)
{
$orders = $request->user()->orders()
->with('items.product:id,name,price') // avoid N+1
->when($request->status, fn ($q, $s) => $q->where('status', $s))
->latest('id')
->cursorPaginate(20); // fast on large tables, stable while new rows arrive
return OrderResource::collection($orders);
}
public function store(StoreOrderRequest $request, OrderService $service)
{
$order = $service->place($request->user(), $request->validated());
return (new OrderResource($order))->response()->setStatusCode(201)
->header('Location', route('orders.show', $order));
}
}
class OrderResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->number, // public id, not the auto-increment
'status' => $this->status,
'total' => ['amount' => $this->total_paise / 100, 'currency' => 'INR'],
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'placed_at' => $this->created_at->toIso8601String(),
];
}
}
3. The same endpoint in Spring Boot
Controller with validation and a DTO
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
class OrderController {
private final OrderService service;
@GetMapping
Page<OrderDto> list(@AuthenticationPrincipal Jwt jwt, @RequestParam(required = false) String status, Pageable pageable) {
return service.list(jwt.getSubject(), status, pageable).map(OrderDto::from);
}
@PostMapping
ResponseEntity<OrderDto> place(@Valid @RequestBody PlaceOrderRequest body, @RequestHeader("Idempotency-Key") String key) {
Order order = service.place(body, key);
return ResponseEntity.created(URI.create("/api/v1/orders/" + order.getNumber())).body(OrderDto.from(order));
}
}
record PlaceOrderRequest(@NotNull Long addressId, @NotEmpty @Size(max = 50) List<@Valid Line> items) {
record Line(@NotNull Long productId, @Min(1) @Max(10) int quantity) {}
}
4. GraphQL: one endpoint, client chooses the fields
GraphQL exposes a single endpoint (POST /graphql) and a typed schema. The client sends a query describing exactly the fields it needs, possibly across several related objects, and gets back JSON in the same shape.
It solves two REST problems. Over-fetching: a mobile order list needs 4 fields but /orders returns 40. Under-fetching: one screen needs order + items + product images + delivery status, which is 4 REST calls. With GraphQL it is one request with only those fields.
Costs: caching by URL no longer works (everything is POST /graphql), a client can ask for very deep or expensive queries (limit depth and complexity), and N+1 queries appear in resolvers unless you batch them (DataLoader, Lighthouse @hasMany batching).
Schema and a query for an order-tracking screen
type Order { id: ID! status: String! total: Float! items: [OrderItem!]! shipment: Shipment }
type OrderItem { quantity: Int! product: Product! }
type Product { name: String! imageUrl: String }
type Query { order(id: ID!): Order }
type Mutation { cancelOrder(id: ID!, reason: String): Order }
query TrackOrder {
order(id: "ORD-187") {
status
items { quantity product { name imageUrl } }
shipment { courier trackingNumber eta }
}
}
Laravel with Lighthouse (schema-first)
# graphql/schema.graphql
type Query {
order(id: ID! @eq(key: "number")): Order @find @guard @can(ability: "view", find: "number")
}
type Order { number: ID! status: String! items: [OrderItem!]! @hasMany }
Spring for GraphQL
@Controller
class OrderGraphController {
@QueryMapping
Order order(@Argument String id) { return orders.findByNumber(id); }
@BatchMapping // loads items for many orders in one query (no N+1)
Map<Order, List<OrderItem>> items(List<Order> orders) { return items.findByOrders(orders); }
}
React with Apollo Client
const TRACK = gql`query($id: ID!) { order(id: $id) { status shipment { courier eta } } }`;
function Tracking({ id }) {
const { data, loading, error } = useQuery(TRACK, { variables: { id } });
if (loading) return <Spinner />;
if (error) return <p>Could not load tracking.</p>;
return <p>{data.order.status} with {data.order.shipment?.courier}, arriving {data.order.shipment?.eta}</p>;
}
| Situation | Prefer |
|---|---|
| Public payment API used by thousands of merchants | REST (simple, cacheable, easy to document and version) |
| Mobile app screens combining many related objects | GraphQL |
| File upload, webhooks, simple CRUD | REST |
| Many teams' services behind one client-facing API | GraphQL gateway / federation |
5. cURL: calling and debugging APIs from the terminal
cURL is a command-line HTTP client available on every server. Engineers use it to test endpoints, reproduce bugs ("send me the curl"), check headers and TLS, and call webhooks. Postman and browser DevTools can export any request as a cURL command.
Everyday cURL commands
# GET with a token, show response headers (-i)
curl -i https://api.shop.test/api/v1/orders/ORD-187 -H "Authorization: Bearer 12|abc..." -H "Accept: application/json"
# POST JSON (place an order) with an idempotency key
curl -X POST https://api.shop.test/api/v1/orders \
-H "Authorization: Bearer 12|abc..." -H "Content-Type: application/json" -H "Idempotency-Key: 7f9c-1" \
-d '{"address_id": 3, "items": [{"product_id": 10, "quantity": 2}]}'
# Upload a file (multipart)
curl -X POST https://bank.test/api/kyc -H "Authorization: Bearer ..." -F "pan=@/home/me/pan.jpg"
# Debug: timing breakdown, verbose TLS handshake, follow redirects
curl -o /dev/null -s -w "dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} total=%{time_total}\n" https://bank.test
curl -v https://bank.test
curl -L http://bank.test # follows the 301 to https
Calling a payment gateway from PHP (Laravel HTTP client is a cURL wrapper)
$response = Http::withBasicAuth(config('services.razorpay.key'), config('services.razorpay.secret'))
->timeout(10)->retry(2, 500, throw: false)
->post('https://api.razorpay.com/v1/payments/' . $paymentId . '/refund', ['amount' => 50000]);
if ($response->failed()) {
throw new RefundFailedException($response->json('error.description'));
}
// Raw PHP cURL, what the wrapper does underneath
$ch = curl_init('https://api.example.com/v1/orders');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Accept: application/json']]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
Always set a timeout when calling another service. Without one, a slow payment gateway keeps your PHP-FPM worker busy until all workers are stuck and the whole site goes down.
6. Swagger / OpenAPI: documenting the API
OpenAPI is a standard YAML/JSON description of an API: endpoints, parameters, request and response schemas, auth and error codes. Swagger UI renders it as an interactive page where developers can try requests. Other tools generate client SDKs, mock servers and contract tests from the same file.
Two approaches: code-first (annotations in controllers generate the spec; springdoc-openapi, L5-Swagger, Scramble) or design-first (write the spec, review it with consumers, then implement). Payment companies usually go design-first because merchants build against the spec before the API exists.
OpenAPI snippet for a refund endpoint
openapi: 3.0.3
info: { title: Payments API, version: "1.0" }
paths:
/v1/payments/{paymentId}/refunds:
post:
summary: Refund a captured payment (full or partial)
security: [{ bearerAuth: [] }]
parameters:
- { name: paymentId, in: path, required: true, schema: { type: string, example: pay_9X2 } }
- { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema: { type: object, required: [amount], properties: { amount: { type: integer, description: "in paise" } } }
responses:
"201": { description: Refund created }
"409": { description: Payment already fully refunded }
"422": { description: Amount exceeds refundable balance }
components:
securitySchemes: { bearerAuth: { type: http, scheme: bearer } }
Spring Boot: springdoc generates /swagger-ui.html from annotations
// implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
@Operation(summary = "Refund a captured payment")
@ApiResponse(responseCode = "201", description = "Refund created")
@ApiResponse(responseCode = "409", description = "Already refunded")
@PostMapping("/v1/payments/{paymentId}/refunds")
RefundDto refund(@PathVariable String paymentId, @Valid @RequestBody RefundRequest body) { ... }
Laravel: L5-Swagger annotations (or Scramble, which infers docs from Form Requests and Resources)
// composer require darkaonline/l5-swagger -> php artisan l5-swagger:generate -> /api/documentation
/**
* @OA\Post(path="/api/v1/orders", summary="Place an order", security={{"sanctum":{}}},
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PlaceOrder")),
* @OA\Response(response=201, description="Created"),
* @OA\Response(response=422, description="Validation failed"))
*/
public function store(StoreOrderRequest $request) { ... }
7. Interview questions
PUT vs PATCH?
PUT replaces the whole resource and is idempotent; PATCH changes some fields.
How do you version a REST API?
URL prefix (/v1) is most common and easy to route; headers are cleaner but harder to test. Never break v1; add v2 and deprecate with a timeline.
How do you stop expensive GraphQL queries?
Depth and complexity limits, pagination on lists, persisted queries, timeouts and DataLoader batching.
Offset vs cursor pagination?
OFFSET scans and discards rows, so page 5,000 is slow and rows shift when new data arrives; cursor (WHERE id < last_id) uses the index and is stable.
