
Content negotiation in PHP: your website is already an API without knowing it (Symfony, Laravel and Temma examples)
I'm preparing a talk on APIs for AFUP Day, the French PHP conference. One of the topics I'll cover is content negotiation, sometimes called "dual-purpose endpoint" or "API mode switch."
The idea is simple: instead of building a separate API alongside your website, you make your website serve both HTML and JSON from the same endpoints. The client signals what it wants, and the server responds accordingly.
A concrete use case
You have a media site or an e-commerce platform. You also have a mobile app that needs the same content, but as JSON. Instead of duplicating your backend logic into a separate API, you expose the same URLs to both your browser and your mobile app. The browser gets HTML, the app gets JSON.
The client signals its preference via the Accept header: Accept: application/json for JSON, Accept: text/html for HTML. Other approaches exist (URL prefix, query parameter, file extension), but the Accept header is the standard HTTP way.
The same endpoint in three frameworks
Symfony
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
class ArticleController extends AbstractController
{
#[Route('/articles', requirements: ['_format' => 'html|json'])]
public function list(Request $request)
{
$data = ['message' => 'Hello World'];
if ($request->getPreferredFormat() === 'json') {
return new JsonResponse($data);
}
return $this->render('articles/list.html.twig', $data);
}
}
In Symfony, the route attribute declares which formats the action accepts. The data is prepared once, then either passed to a Twig template for HTML rendering, or serialized as JSON using JsonResponse depending on what the client requested.
Laravel
Laravel has no declarative format constraint at the route level. The detection happens in the controller.
routes/web.php
<?php
use App\Http\Controllers\ArticleController;
use Illuminate\Support\Facades\Route;
Route::get('/articles', [ArticleController::class, 'list']);
Unlike Symfony, there is no need to declare accepted formats in the route. The detection happens in the controller via expectsJson().
app/Http/Controllers/ArticleController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class ArticleController extends Controller
{
public function list(Request $request)
{
$data = ['message' => 'Hello World'];
if ($request->expectsJson()) {
return response()->json($data);
}
return view('articles.list', $data);
}
}
The data is prepared once, then either serialized as JSON via response()->json(), or passed to a Blade template for HTML rendering.
Temma controllers/Article.php
<?php
use \Temma\Attributes\View as TµView;
class Article extends \Temma\Web\Controller {
#[TµView(negotiation: 'html, json')]
public function list() {
$this['message'] = 'Hello World';
}
}
In Temma, the approach is different from Symfony and Laravel: the action doesn't have to check what format the client is asking for. Its code is always the same, regardless of whether the client wants HTML or JSON. A view attribute handles the format selection automatically, based on the Accept header sent by the client.
Here, the attribute is placed on the action, but it could be placed on the controller instead, in which case it would apply to all actions.