0

Safe methods — HTTP methods that must not change server state

Intermediate5 min read·eng-13-003
interview

Concept

Safe methods — HTTP methods that, by definition, MUST NOT cause any side effects on the server. A safe request is one the client can make without expecting any state change.

Safe methods: GET, HEAD, OPTIONS, TRACE. Unsafe methods: POST, PUT, PATCH, DELETE — these change server state.

What "safe" means precisely: The HTTP specification (RFC 9110) says safe methods are requests where "the client does not request, and does not expect, any state change on the origin server as a result of applying that method." This is a SEMANTIC contract, not a technical enforcement.

Important nuances:

  • "Safe" doesn't mean the server can't log the request, update access counters, or do other non-observable operations. Those are incidental side effects.
  • If you implement GET to delete records, it's technically possible but violates the safe-method contract. Crawlers and caches will break.
  • Browsers prefetch links with GET — safe methods guarantee no accidental mutations from prefetching.

Safe vs idempotent comparison:

MethodSafe?Idempotent?
GETYesYes
HEADYesYes
OPTIONSYesYes
PUTNoYes
DELETENoYes
POSTNoNo
PATCHNoDepends

Practical consequences:

  • Caches (browsers, CDNs, proxies) only cache safe methods by default.
  • Search engine crawlers only follow GET links — if you route a delete action to a GET URL, crawlers will delete your data.
  • CSRF protection is often skipped for GET requests — they're expected to be safe.
  • Laravel's VerifyCsrfToken middleware excludes GET, HEAD, OPTIONS by default.

Code Example

php
<?php
// ✅ SAFE — GET should ONLY read, never write
Route::get('/users/{id}', function (int $id) {
    return response()->json(User::findOrFail($id));
    // No state change — safe to call by caches, crawlers, prefetchers
});

// ❌ UNSAFE GET — this violates the safe-method contract
Route::get('/users/{id}/delete', function (int $id) {
    User::findOrFail($id)->delete(); // WRONG! GET must not mutate state
    return redirect('/users');
    // A search engine crawling this URL will delete users!
});

// ✅ Correct — use DELETE method for deletion
Route::delete('/users/{id}', function (int $id) {
    User::findOrFail($id)->delete();
    return response()->noContent(); // 204
});

// HEAD — same as GET but returns headers only
// Used to check if a resource exists or get metadata without downloading the body
Route::get('/files/{id}', function (int $id) {
    $file = File::findOrFail($id);
    return response()
        ->file(storage_path("uploads/{$file->path}"))
        ->header('ETag', $file->hash)
        ->header('Content-Length', $file->size); // HEAD will return these headers only
});

// OPTIONS — describes what methods a resource supports
// Laravel/Symfony handle OPTIONS automatically for CORS preflight
// You can also add it manually:
Route::options('/users', function () {
    return response('', 204)->header('Allow', 'GET, POST, OPTIONS');
});

// CSRF protection — Laravel skips for safe methods
// In VerifyCsrfToken.php, GET/HEAD/OPTIONS skip the CSRF check:
// protected function isReading(Request $request): bool
// {
//     return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS']);
// }

// Safe methods are cacheable — CDN caches GET responses automatically
// Unsafe methods invalidate caches
Route::get('/products', function () {
    return response()
        ->json(Product::active()->get())
        ->header('Cache-Control', 'public, max-age=300'); // CDN can cache this!
});

Route::post('/products', function (CreateProductRequest $request) {
    $product = Product::create($request->validated());
    // POST response is NOT cached by default — unsafe method
    return response()->json($product, 201);
});