How to Fix the PHP Error Message in JavaScript with Fetch

When I was working on a Symfony project, I ran into a common frustration: I wanted my backend (PHP/Symfony) to send custom error messages to the frontend and then display those messages in JavaScript using fetch.

On the surface, it looked simple. But the reality was different: fetch didn’t give me the custom error message. Instead, it only returned the generic HTTP status text like Bad Request. Let me show you what went wrong and how I fixed it.

The Problem Code

Here’s the first version of my code:

PHP (Symfony Controller)

#[Route('/test', name:'test', methods: ['POST'])]
public function test(Request $req): Response
{
    return new JsonResponse(['error' => 'my Custom Error'], 400);
}

This looks good I’m returning a JSON error with status code 400.

JavaScript

let btn = document.getElementById('myButton');
btn.addEventListener('click', function(event){
  const fd = new FormData();
  fd.append('user', 'myUserName');

  fetch('/test', {method: 'POST', body: fd})
    .then((response) => {
      if (!response.ok) {
        throw Error(response.statusText); 
        //  Only gives back "Bad Request"
        //  Does not include my JSON error
      }
      return response.json();
    })
    .then((data) => {
      console.log('data received', data);
    })
    .catch((error) => {
      console.log(error);
    });
});

The Error

The mistake became clear:

  • response.statusText → only returns generic messages like Bad Request.
  • response.error() → doesn’t exist at all, so it throws TypeError: response.error is not a function.
  • My custom JSON error ({"error":"my Custom Error"}) was ignored completely.

The .catch block never got the real backend error message, only the generic status. That wasn’t helpful.

The Fix

The key was this: even when the response is an error, I can still read its body. That means I should call response.json() before throwing an error.

Here’s the improved version:

let btn = document.getElementById('myButton');
btn.addEventListener('click', function(event){
  const fd = new FormData();
  fd.append('user', 'myUserName');

  fetch('/test', {method: 'POST', body: fd})
    .then(async (response) => {
      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.error || 'Unknown error occurred');
      }
      return response.json();
    })
    .then((data) => {
      console.log('data received:', data);
    })
    .catch((error) => {
      console.error(' Server error:', error.message);
    });
});

Example Output

If Symfony returned:

{"error": "my Custom Error"}

Then my JavaScript console now correctly shows:

 Server error: my Custom Error

Finally, the backend message made it through to the frontend.

Extra Practice Functionality

I didn’t want to stop there. I wanted this code to be user-friendly, not just developer-friendly. So, I added some enhancements:

  1. Show messages directly in the page (not just console).
  2. Disable the button while the request is running (to prevent double-clicks).
  3. Handle both success and error cases cleanly.

Here’s the enhanced version:

let btn = document.getElementById('myButton');
let output = document.getElementById('output');

btn.addEventListener('click', async function(event){
  const fd = new FormData();
  fd.append('user', 'myUserName');

  btn.disabled = true; 
  output.textContent = " Processing...";

  try {
    const response = await fetch('/test', { method: 'POST', body: fd });

    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(errorData.error || 'Unknown error');
    }

    const data = await response.json();
    output.textContent = " Success: " + JSON.stringify(data);
  } catch (error) {
    output.textContent = "Error: " + error.message;
  } finally {
    btn.disabled = false; 
  }
});

Example HTML

<button id="myButton">Send</button>
<p id="output"></p>

Now, instead of looking at the console, users immediately see feedback right on the page.

Final Thought

This little debugging journey taught me a valuable lesson fetch won’t magically give you custom server errors you have to extract them yourself. By parsing the response body (response.json()) even when the request fails, I now have full control over how my Symfony backend communicates with the frontend. And by adding small UI touches (like disabling the button and showing messages), I created a better experience for users.

Related blog posts