Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

On Authentication Success

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

If you refresh the page and check the web debug toolbar, you can see that we're not logged in. Let's try using a real email and password. We can cheat by clicking the email and password links: this user exists in our AppFixtures, so it should work. And... okay... the boxes disappear! But nothing else happens. We'll improve that in a minute.

Thanks Session!

But for now, refresh the page and look at the web debug toolbar again. We're authenticated! Yea! Just by making a successful AJAX request to that login endpoint, that was enough to create the session and keep us logged in. Even better, if we started making requests to our API from JavaScript, those requests would be authenticated too. That's right! We don't need a fancy API token system where we attach a token to every request. We can just make a request and through the magic of cookies, that request will be authenticated.

REST and What Data to Return from our Authentication Endpoint?

So, logging in worked... but nothing happened on the page. What should we do after authentication? Once again, it doesn't really matter. If you're writing your auth system for your own JavaScript, you should do whatever is useful for your frontend. We're currently returning the user id. But we could, if we wanted, return the entire user object as JSON.

But there's one tiny problem with that. It's not super RESTful. This is one of those "REST purity" things. Every URL in your API, on a technical level, represents a different resource. This represents the collection resource, and this URL represents a single User resource. And if you have a different URL, that's understood to be a different resource. The point is that, in a perfect world, you would just return a User resource from a single URL instead of having five different endpoints to fetch a user.

If we return the User JSON from this endpoint, we're "technically" creating a new API resource. In fact, anything we return from this endpoint, from a REST point of view, becomes a new resource in our API. To be honest, this is all technical semantics and you should feel free to do whatever you want. But, I do have a fun suggestion.

Returning the IRI

To try be helpful to our frontend and somewhat RESTful, I have another idea. What if we return nothing from the endpoint.... but sneak the user's IRI onto the Location header of the response. Then, our frontend could use that to know who just logged in.

Let me show you. First, instead of returning the User ID, we're going to return the IRI, which will look something like '/api/users/'.$user->getId(). But I don't want to hard code that because we could potentially change the URL in the future. I'd rather have API Platform generate that for me.

And fortunately, API Platform gives us an autowireable service to do that! Before the optional argument, add a new argument type-hinted with IriConverterInterface and call it $iriConverter:

... lines 1 - 4
use ApiPlatform\Api\IriConverterInterface;
... lines 6 - 10
class SecurityController extends AbstractController
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] $user = null): Response
... lines 16 - 24

Then, down here, return new Response() (the one from HttpFoundation) with no content and a 204 status code:

... lines 1 - 10
class SecurityController extends AbstractController
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] $user = null): Response
... lines 16 - 21
return new Response(null, 204, [
... line 23

The 204 means it was "successful... but there's no content to return". We'll also pass a Location header set to $iriConverter->getIriFromResource():

... lines 1 - 10
class SecurityController extends AbstractController
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] $user = null): Response
... lines 16 - 21
return new Response(null, 204, [
'Location' => $iriConverter->getIriFromResource($user),

So you can get the resource from an IRI or the IRI string from the resource, the resource being your object. Pass this $user.

Using the IRI in JavaScript

How nice is that? Now that we're returning this how can we use this in JavaScript? Ideally, after we log in, we would automatically show some user info over on the right. This area is built by another Vue file called TreasureConnectApp.vue:

<div class="purple flex flex-col min-h-screen">
... lines 3 - 5
<div class="flex-auto flex flex-col sm:flex-row justify-center px-8">
class="book shadow-md rounded sm:ml-3 px-8 pt-8 pb-8 mb-4 sm:w-1/2 md:w-1/3 text-center">
<div v-if="user">
Authenticated as: <strong>{{ user.username }}</strong>
| <a href="/logout" class="underline">Log out</a>
<div v-else>Not authenticated</div>
... lines 17 - 20
... line 23
<script setup>
import { ref } from 'vue';
import LoginForm from '../LoginForm';
import coinLogoPath from '../../images/coinLogo.png';
import goldPilePath from '../../images/GoldPile.png';
const user = ref(null);
const onUserAuthenticated = async (userUri) => {
const response = await fetch(userUri);
user.value = await response.json();

I won't go into the details, but as long as that component has user data, it will print it out here. And LoginForm.vue is already set up to pass that user data to TreasureConnectApp.vue. Down at the bottom, after a successful authentication, this is where we clear the email and password state, which empties the boxes after we log in. If we emit an event called user-authenticated and pass it the userIri, TreasureConnectApp.vue is already set up to listen to this event. It will then make an AJAX request to userIri, get the JSON back, and populate its own data.

If you're not comfortable with Vue, that's ok. The point is that all we need to do is grab the IRI string from the Location header, emit this event, and everything should work.

To read the header, say const userIri = response.headers.get('Location'). I'll also uncomment this so we can emit it:

... lines 1 - 48
<script setup>
... lines 50 - 65
const handleSubmit = async () => {
... lines 67 - 89
email.value = '';
password.value = '';
const userIri = response.headers.get('Location');
emit('user-authenticated', userIri);

This should be good! Move over and refresh. The first thing I want you to notice is that we're still logged in, but our Vue app doesn't know that we're logged in. We're going to fix that in a minute. Log in again using our valid email and password. And... beautiful! We made the POST request, it returned the IRI and then our JavaScript made a second request to that IRI to fetch the user data, which it displayed here.

Next: Let's talk about what it means to log out of an API. Then, I'll show you a simple way of telling your JavaScript who is logged in on page load. Because, right now, even though we are logged in, as soon as I refresh, our JavaScript thinks we're not. Lame.

Leave a comment!

Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0