Une API en 5 min c’est possible (avec Symfony)

comment implémenter une api en 5 min ?

Une api c’est simple en soit et peut devenir une horreur suivant comment et le pourquoi on est contraint de faire des choix technique poussé

Introduction :

En 2025, les APIs ne sont plus un luxe réservé aux architectes logiciels : elles sont devenues la norme dans le développement web. Que ce soit pour connecter un front VueJS, une appli mobile ou pour échanger entre microservices, savoir générer une API rapidement est devenu un savoir-faire essentiel.

Mais peut-on vraiment créer une API complète en moins de 5 minutes ? La réponse est oui, grâce à Symfony 7.3API Platform, et un peu de magie open-source. Pas besoin de se perdre dans la configuration à rallonge : on ira droit au but.

👉 Ici, on ne s’attarde pas sur la sécurité (même si elle est cruciale), mais sur l’essentiel : comment mettre en place une API RESTful fonctionnelle, documentée et prête à l’emploi en un temps record.

🎁 Bonus : on fera une petite intégration en VueJS 3, pour consommer dynamiquement les données via des fixtures Symfony. Un setup minimaliste, 100 % Symfony-compliant, basé sur :

  • Symfony 7.3
  • SQLite pour une base ultra-légère
  • VueJS 3 pour le front
  • Et bien sûr API Platform

🔗 L’ensemble du code source est disponible à la fin de l’article pour te permettre de démarrer instantanément.

Générer des données de test

Pas de blabla, du concret. Pour tester rapidement notre API, on a besoin de données réalistes. Et pour ça, on va coupler deux bundles redoutables :

class Product
{
    private string $name;
    private string $description;
    private float $price;
}
class Category
{
    private string $name;
    
    #[ORM\OneToMany(mappedBy: 'category', targetEntity: Product::class)]
    private Collection $products;
}

📦 Une catégorie peut contenir plusieurs produits. Simple, logique.

Mise en place des fixtures avec Foundry

  1. Installation des bundles
composer require zenstruck/foundry --dev
composer require doctrine/doctrine-fixtures-bundle --dev

2. Génération des factories

php bin/console make:factory Product
php bin/console make:factory Category

3. Customisation avec FakerPHP

Par défaut, Foundry utilise faker->text() pour toutes les chaînes. Mauvais choix pour des champs comme price ou name. On va donc ajuster.

// ProductFactory
protected function getDefaults(): array
{
    return [
        'name' => self::faker()->word(),
        'description' => self::faker()->paragraph(),
        'price' => self::faker()->randomFloat(2, 5, 150),
        'category' => CategoryFactory::new(),
    ];
}
// CategoryFactory
protected function getDefaults(): array
{
    return [
        'name' => self::faker()->word(),
    ];
}

4. Charger les fixtures pour flush

<?php

namespace App\DataFixtures;

use App\Factory\CategoryFactory;
use App\Factory\ProductFactory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager): void
    {
        CategoryFactory::createMany(5);
        ProductFactory::createMany(50, function() {
            return [
                'category' => CategoryFactory::random(),
            ];
        });
        $manager->flush();
    }
}

5. Lancement des fixtures

php bin/console doctrine:fixtures:load

💥 Résultat : 50 produits générés aléatoirement, associés à 5 catégories différentes… en moins de 1 seconde.

Modifier les entités et c’est tout !

La magie d’API Platform, c’est sa capacité à s’intégrer directement à nos entités Symfony. Pas besoin de contrôleurs manuels, ni de sérialisation bricolée : une simple annotation #[ApiResource] et l’API REST prend vie.

Ici, lecture seule (GET only)

Dans notre cas, on veut que le frontend consume l’API, mais pas qu’il puisse modifier quoi que ce soit. On restreint donc les opérations disponibles :

#[ApiResource(
    operations: [
        new Get(),
        new GetCollection()
    ],
    normalizationContext: ['groups' => ['product:read']]
)]

👉 Résultat :

  • Le frontend peut faire un GET /api/products ou GET /api/products/{id}
  • Aucun POSTPUTDELETE possible

Sérialisation intelligente avec les groupes

API Platform s’appuie sur les groupes de sérialisation Symfony (@Groups) pour contrôler finement les champs exposés en JSON.

#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['product:read'])]
private ?int $id = null;

#[ORM\Column(length: 255)]
#[Groups(['product:read'])]
private ?string $name = null;

#[ORM\Column(length: 255)]
#[Groups(['product:read'])]
private ?string $description = null;

#[ORM\Column]
#[Groups(['product:read'])]
private ?float $price = null;

#[ORM\ManyToOne(inversedBy: 'product')]
#[Groups(['product:read'])]
private ?Category $category = null;

Avec ça :

  • Pas de boucles infinies lors de la sérialisation (classique avec des relations bidirectionnelles).
  • API claire, concise et orientée front-end : parfaite pour du VueJS.

Résultat immédiat

Une fois la route GET /api/products appelée, API Platform :

  • Charge les entités Product
  • Sérialise automatiquement en JSON structuré
  • Inclut les relations (ex : nom de la catégorie) si groupé proprement

Et ce, sans une seule ligne de contrôleur, ni transformer la donnée à la main. Un comportement sur-mesure en 1 minute.

{
"@context": "/api/contexts/Product",
"@id": "/api/products",
"@type": "Collection",
"totalItems": 50,
"member": [
{
"@id": "/api/products/1",
"@type": "Product",
"id": 1,
"name": "quia",
"description": "Quisquam atque atque quia at doloribus nemo. Fuga sit sed soluta qui consectetur. Et velit assumenda voluptatem repudiandae vel.",
"price": 2915125.27,
"category": {
"@type": "Category",
"@id": "/api/.well-known/genid/dbbed7255ea37800e84d",
"id": 1
}
},
{
"@id": "/api/products/2",
"@type": "Product",
"id": 2,
"name": "qui",
"description": "Illo aut eaque quod ea nulla sequi. Velit perspiciatis quia excepturi rem. Doloribus ut dolore dignissimos omnis aut vero consequatur. Et occaecati quos fugiat ullam quia reprehenderit odit impedit.",
"price": 341235789.62,
"category": {
"@type": "Category",
"@id": "/api/.well-known/genid/59446710cd925927c232",
"id": 3
}
},

💡 Remarques clés :

  • Le format suit Hydra (JSON-LD), pris en charge automatiquement par API Platform.
  • La relation avec Category est déjà résolue via @id : prêt pour être fetch ou peuplé.
  • Le champ totalItems confirme bien le chargement de 50 produits simulés.

Résultat immédiat :

Tu viens de générer une collection RESTful paginée, typée, documentée, prête à intégrer un front JS. Et tout cela sans écrire un seul contrôleur Symfony.

Ajouter une couche front avec VueJS

Maintenant que notre API Symfony tourne à plein régime, il est temps de connecter une interface utilisateur simple, moderne et réactive. Pour cela, j’ai choisi VueJS. Pourquoi Vue ? Parce qu’en 2025, ça reste un framework intuitifléger, et ultra-rapide à intégrer dans un projet Symfony, même sans se prendre la tête avec TypeScript ou Vite.

🎯 Objectif ici :

  • Un front VueJS 3 sans TypeScript.
  • PicoCSS pour un style minimaliste et propre.
  • Webpack Encore comme outil de build, pour garder la logique Symfony (Turbo, Stimulus…) intacte.

Mise en place de Webpack Encore

1. Installation de Webpack encore

composer require symfony/webpack-encore-bundle
yarn install
yarn add @symfony/webpack-encore --dev

2. On ajoute vuejs et picocss

yarn add vue@next
yarn add picocss

3. Le fichier Webpack encore

const Encore = require('@symfony/webpack-encore');

// Manually configure the runtime environment if not already configured yet by the "encore" command.
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
    Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}

Encore
    // directory where compiled assets will be stored
    .setOutputPath('public/build/')
    // public path used by the web server to access the output path
    .setPublicPath('/build')
    // only needed for CDN's or subdirectory deploy
    //.setManifestKeyPrefix('build/')

    /*
     * ENTRY CONFIG
     *
     * Each entry will result in one JavaScript file (e.g. app.js)
     * and one CSS file (e.g. app.css) if your JavaScript imports CSS.
     */
    .addEntry('app', './assets/app.js')  // ← AJOUTEZ CETTE LIGNE
    .addEntry('products-vue', './assets/js/products-vue.js')

    // When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
    .splitEntryChunks()

    // will require an extra script tag for runtime.js
    // but, you probably want this, unless you're building a single-page app
    .enableSingleRuntimeChunk()

    /*
     * FEATURE CONFIG
     *
     * Enable & configure other features below. For a full
     * list of features, see:
     * https://symfony.com/doc/current/frontend.html#adding-more-features
     */
    .cleanupOutputBeforeBuild()

    // Displays build status system notifications to the user
    // .enableBuildNotifications()

    .enableSourceMaps(!Encore.isProduction())
    // enables hashed filenames (e.g. app.abc123.css)
    .enableVersioning(Encore.isProduction())

    // configure Babel
    // .configureBabel((config) => {
    //     config.plugins.push('@babel/a-babel-plugin');
    // })

    // enables and configure @babel/preset-env polyfills
    .configureBabelPresetEnv((config) => {
        config.useBuiltIns = 'usage';
        config.corejs = '3.38';
    })

    // enables Sass/SCSS support
    //.enableSassLoader()

    // uncomment if you use TypeScript
    //.enableTypeScriptLoader()

    // uncomment if you use React
    //.enableReactPreset()

    .enableVueLoader()  // ← Déplacé après les entrées pour plus de clarté

// uncomment to get integrity="..." attributes on your script & link tags
// requires WebpackEncoreBundle 1.4 or higher
//.enableIntegrityHashes(Encore.isProduction())

// uncomment if you're having problems with a jQuery plugin
//.autoProvidejQuery()
;

module.exports = Encore.getWebpackConfig();

Pourquoi Webpack plutôt que Vite ?

Beaucoup utilisent Vite en 2025, mais ici, on garde Webpack pour une raison simple : tirer parti de Turbo et Stimulus, toujours largement utilisés dans l’écosystème Symfony pour la gestion des pages Twig, formulaires dynamiques, et navigation sans reload.

🔗 En combinant VueJS via Webpack avec le moteur de template Twig, on obtient :

  • Un front dynamique pour les appels API.
  • Une base Symfony classique compatible avec tous les bundles (form, security, profiler…).
  • Une compatibilité parfaite avec du SSR ou du SSG si besoin plus tard.

VueJS 3 + PicoCSS : un front modulaire connecté à Symfony

Rien de bien complexe ici : dans un POC (Proof of Concept), l’objectif est de visualiser nos données rapidement. VueJS s’y prête parfaitement : une syntaxe fluide, une intégration douce via Webpack, et un DOM réactif dès la récupération de notre API /api/products.

✨ Composant VueJS : ProductsList.vue

Voici un composant Vue 3 autonome, pensé pour la lisibilité et la pagination automatique :

  • Affiche un grid de produits avec namedescriptionpricecategory.
  • Utilise l’API Fetch native pour consommer l’API Symfony.
  • Intègre une pagination simple sur clic (load more).
  • Mise en forme rapide grâce à PicoCSS.

💡 Le composant se veut simple, sans TypeScript, sans store VueX/Pinia, mais suffisamment propre pour évoluer.

👉 Code complet du composant 

<template>
    <div class="products-container">
        <h2>Liste des Produits ({{ totalItems }} produits)</h2>

        <div v-if="loading" class="loading">
            Chargement des produits...
        </div>

        <div v-else-if="error" class="error">
            Erreur lors du chargement : {{ error }}
        </div>

        <div v-else class="products-grid">
            <div
                v-for="product in products"
                :key="product.id"
                class="product-card"
            >
                <h3>{{ product.name }}</h3>
                <p v-if="product.description" class="description">{{ product.description }}</p>
                <p v-if="product.price" class="price">{{ formatPrice(product.price) }}</p>
                <p v-if="product.category" class="category">
                    Catégorie ID: {{ product.category.id }}
                </p>
            </div>
        </div>

        <!-- Pagination simple -->
        <div v-if="!loading && !error && hasNextPage" class="pagination">
            <button @click="loadMore" :disabled="loadingMore">
                {{ loadingMore ? 'Chargement...' : 'Charger plus' }}
            </button>
        </div>
    </div>
</template>

<script>
export default {
    name: 'ProductsList',
    data() {
        return {
            products: [],
            totalItems: 0,
            currentPage: 1,
            hasNextPage: false,
            loading: true,
            loadingMore: false,
            error: null
        }
    },
    async mounted() {
        await this.fetchProducts();
    },
    methods: {
        async fetchProducts(page = 1) {
            try {
                if (page === 1) {
                    this.loading = true;
                } else {
                    this.loadingMore = true;
                }

                const response = await fetch(`/api/products?page=${page}`);

                if (!response.ok) {
                    throw new Error(`Erreur HTTP: ${response.status}`);
                }

                const data = await response.json();

                // Correction : utiliser 'member' au lieu de 'hydra:member'
                const newProducts = data.member || [];

                if (page === 1) {
                    this.products = newProducts;
                } else {
                    this.products = [...this.products, ...newProducts];
                }

                this.totalItems = data.totalItems || 0;
                this.currentPage = page;

                // Vérifier s'il y a une page suivante
                this.hasNextPage = data.view && data.view.next;

            } catch (error) {
                this.error = error.message;
                console.error('Erreur lors du fetch:', error);
            } finally {
                this.loading = false;
                this.loadingMore = false;
            }
        },

        async loadMore() {
            if (this.hasNextPage && !this.loadingMore) {
                await this.fetchProducts(this.currentPage + 1);
            }
        },

        formatPrice(price) {
            return new Intl.NumberFormat('fr-FR', {
                style: 'currency',
                currency: 'EUR'
            }).format(price);
        }
    }
}
</script>

<style scoped>
.products-container {
    padding: 1rem;
}

.products-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
    gap: 1rem;
    margin-top: 1rem;
}

.product-card {
    border: 1px solid var(--pico-border-color);
    border-radius: var(--pico-border-radius);
    padding: 1rem;
    border-radius: 10px;
    background: var(--pico-background-color);
    transition: transform 0.2s ease;
}

.product-card:hover {
    transform: translateY(-2px);
    cursor: pointer;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}

.product-card h3 {
    margin: 0 0 0.5rem 0;
    color: var(--pico-color);
    text-transform: capitalize;
}

.description {
    color: var(--pico-muted-color);
    font-size: 0.9rem;
    line-height: 1.4;
    margin: 0.5rem 0;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.price {
    font-weight: bold;
    color: var(--pico-primary);
    margin: 0.5rem 0;
    font-size: 1.1rem;
}

.category {
    font-size: 0.8rem;
    color: var(--pico-muted-color);
    margin: 0;
    padding: 0.25rem 0.5rem;
    background: var(--pico-code-background-color);
    border-radius: 4px;
    display: inline-block;
}

.loading, .error {
    text-align: center;
    padding: 2rem;
}

.error {
    color: var(--pico-del-color);
}

.pagination {
    text-align: center;
    margin-top: 2rem;
}

.pagination button {
    padding: 0.75rem 1.5rem;
    background: var(--pico-primary);
    color: white;
    border: none;
    border-radius: var(--pico-border-radius);
    cursor: pointer;
    font-size: 1rem;
}

.pagination button:disabled {
    opacity: 0.6;
    cursor: not-allowed;
}

.pagination button:not(:disabled):hover {
    background: var(--pico-primary-hover);
}
</style>

🛠 Intégration Twig & Routing

On crée une route dédiée dans le contrôleur :

#[Route('/products-vue')]
public function index(): Response
{
    return $this->render('products_vue/index.html.twig');
}

et ce twig associé :

{% extends 'base.html.twig' %}

{% block title %}Produits Vue.js{% endblock %}

{% block stylesheets %}
    {{ parent() }}
    {{ encore_entry_link_tags('products-vue') }}
{% endblock %}

{% block body %}
    <div id="products-vue-app"></div>
{% endblock %}

{% block javascripts %}
    {{ parent() }}
    {{ encore_entry_script_tags('products-vue') }}
{% endblock %}

et entrypoint JS :

import { createApp } from 'vue';
import ProductsList from '../vue/ProductsList.vue';
import '../styles/app.css';

createApp(ProductsList).mount('#products-vue-app');

En lançant simplement :

npm run build

➡️ Tu obtiens une SPA légère encapsulée dans un projet Symfony, qui :

  • Récupère dynamiquement les produits.
  • Affiche les données au format JSON (Hydra).
  • Pagine automatiquement.
  • Est intégrée dans le même écosystème que Turbo/Stimulus, sans conflit.

liste des produits via Vuejs3 et api platform

Conclusion

S’il y a bien une chose à retenir, c’est qu’en 2025, il est non seulement possible, mais ultra rapide de mettre en place une API complète avec Symfony et API Platform.

Mais pas juste une API « hello world » :

  • Une base de données (ici SQLite pour la rapidité)
  • Des entités relationnelles (Product & Category)
  • Des fixtures dynamiques avec Zenstruck Foundry
  • Une documentation Swagger auto-générée
  • Et même un front VueJS 3 qui hydrate dynamiquement le DOM à partir des endpoints REST

Tout ça, en quelques minutes, et le tout 100 % Symfony compliant.


Mais restons clairs : ce POC n’est pas encore prêt pour la production :

  • Pas d’authentification (pas de JWT)
  • Aucune ressource protégée
  • Tout est accessible publiquement

👉 Cependant, nous avons déjà implémenté une logique API customisée :

  • GET uniquement (aucune mutation autorisée)
  • Sérialisation fine grâce aux groupes
  • API lisible, scalable, et prête pour évoluer

🎯 En résumé :
API Platform, couplé à l’écosystème Symfony, est l’outil rêvé pour :

  • Prototyper une idée
  • Démarrer un MVP
  • Créer un back-end simple et documenté en un clin d’œil

Et oui, en 5 minutes, on peut littéralement lancer une API avec de vraies données, un front VueJS, et un rendu JSON prêt à consommer.

dashboard api platform jschristophe

Cet article t’a plu ?

Sur ce blog, on teste, on bidouille, on POC tout ce qui nous passe sous la main 😎
Et ce projet ne fait pas exception.

👉 Le code source complet est dispo ici

Tu veux aller plus loin après ce petit POC API ?
➡️ Regarde comment intégrer FrankenPHP dans un projet Symfony : un serveur PHP nouvelle génération qui fait trembler Nginx et Apache. Oui, oui, on l’a testé aussi 💥

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *