Doxygen does not document one PHP class

Issue

I have a small Symfony project I want to document with doxygen. There are two .php files that should be included. One is documented, the other is not, and I cannot figure out why that may be.

Folder structure is:

project
└--src
   |--Controller
   |  └--FormController.php
   └--Model
      └--Inquiry.php

Doxygen is reading and parsing both files…

Reading /form-handler/src/Controller/FormController.php…

Parsing file /form-handler/src/Controller/FormController.php…

Reading /form-handler/src/Model/Inquiry.php…

Parsing file /form-handler/src/Model/Inquiry.php…

…but only documents FormController.php, not Inquiry.php:

Generating docs for compound App::Controller::FormController…

For some reason doxygen does not seem to recognizeInquiry.php as a class.
What I have tried:

  1. Removed decorators from docstrings that might offend doxygen.
  2. Checked format of docstrings
  3. Enabled/disabled various Doxyfile options

FormController.php:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Model\Inquiry;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
 * Handles incoming requests.
 */
class FormController extends AbstractController
{
    /**
     * Handles POST requests.
     *
     * @return Response contains JSON object with 'message' value
     */
    #[Route('/', methods: ['POST'])]
    public function handleRequest(
        HttpClientInterface $client,
        Inquiry $inquiry,
        LoggerInterface $logger,
        RateLimiterFactory $formApiLimiter,
        Request $request,
    ): Response {
        $logger->debug('Received a POST request');

        // set up a rate limiter by IP
        // rules are defined in /config/packages/rate-limiter.yaml
        $limiter = $formApiLimiter->create($request->getClientIp());

        $limit = $limiter->consume();

        // configure headers exposing rate limit info
        $headers = [
            'Content-Type' => 'application/json',
            'X-RateLimit-Remaining' => $limit->getRemainingTokens(),
            'X-RateLimit-Retry-After' => $limit->getRetryAfter()->getTimestamp(),
            'X-RateLimit-Limit' => $limit->getLimit(),
        ];

        if (false === $limit->isAccepted()) {
            return new Response(
                content: json_encode(['message' => null]),
                status: Response::HTTP_TOO_MANY_REQUESTS,
                headers: $headers
            );
        }

        // make sure all required fields are included in request and not empty
        $requiredFields = ['subject', 'message', 'consent', 'h-captcha-response'];
        $providedFields = $request->request->keys();

        foreach ($requiredFields as $field) {
            if (!in_array($field, $providedFields)) {
                return new Response(
                    content: json_encode(['message' => "Pflichtfeld '".$field."' fehlt."]),
                    status: Response::HTTP_BAD_REQUEST,
                    headers: $headers
                );
            } elseif ('' == filter_var($request->request->get($field), FILTER_SANITIZE_SPECIAL_CHARS)) {
                return new Response(
                    content: json_encode(['message' => "Pflichtfeld '".$field."' darf nicht leer sein."]),
                    status: Response::HTTP_BAD_REQUEST,
                    headers: $headers
                );
            }
        }

        // verify captcha success
        $captcha = filter_var($request->request->get('h-captcha-response'), FILTER_SANITIZE_SPECIAL_CHARS);
        $data = [
            'secret' => $this->getParameter('app.captcha.secret'),
            'response' => $captcha,
        ];

        try {
            $hCaptchaResponse = $client->request(
                method: 'POST',
                url: 'https://hcaptcha.com/siteverify',
                options: [
                    'body' => $data,
                ],
            );

            $hCaptchaResponseJson = json_decode($hCaptchaResponse->getContent(), true);

            if (!$hCaptchaResponseJson['success']) {
                return new Response(
                    content: json_encode(['message' => 'Captcha fehlgeschlagen']),
                    status: Response::HTTP_BAD_REQUEST,
                    headers: $headers
                );
            }
            // exceptions on the side of hCaptcha are logged, but the request is processed anyway
        } catch (TransportExceptionInterface $e) {
            $logger->debug('Could not reach hCaptcha verification server: '.$e);
        } catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface $e) {
            $logger->debug('Error when verifying hCaptcha response: '.$e);
        }

        // get values from request data
        $name = filter_var($request->request->get('name'), FILTER_SANITIZE_SPECIAL_CHARS);
        $email = filter_var($request->request->get('email'), FILTER_SANITIZE_EMAIL);
        $phone = filter_var($request->request->get('phone'), FILTER_SANITIZE_SPECIAL_CHARS);
        $subject = filter_var($request->request->get('subject'), FILTER_SANITIZE_SPECIAL_CHARS);
        $message = filter_var($request->request->get('message'), FILTER_SANITIZE_SPECIAL_CHARS);
        $consent = filter_var($request->request->get('consent'), FILTER_SANITIZE_SPECIAL_CHARS);
        // translate into a boolean (else the string 'false' will be evaluated as true)
        $consent = filter_var($consent, FILTER_VALIDATE_BOOLEAN);

        // populate Inquiry with request data
        $inquiry->createInquiry(
            subject: $subject,
            message: $message,
            consent: $consent,
            name: $name,
            email: $email,
            phone: $phone,
        );

        // validate Inquiry
        $validationResult = $inquiry->validateInquiry();
        // if Inquiry is invalid, return validation violation message(s)
        if (count($validationResult) > 0) {
            $logger->debug($validationResult);

            // assemble list of error messages
            $validationMessages = [];
            foreach ($validationResult as $result) {
                $validationMessages += [$result->getPropertyPath() => $result->getMessage()];
            }

            return new Response(
                content: json_encode([
                    'message' => 'Anfrage enthält ungültige Werte',
                    'errors' => $validationMessages,
                ]),
                status: Response::HTTP_BAD_REQUEST,
                headers: $headers
            );
        }
        // send mail to office
        $emailResult = $inquiry->sendOfficeEmail();
        $logger->debug(implode(' ,', $emailResult));
        $message = 'Die Anfrage war erfolgreich';
        if (!$emailResult['success']) {
            $message = 'Die Anfrage war nicht erfolgreich.';
        }

        // TODO compile email to user

        $data = [
            'message' => $message,
            'officeEmail' => $emailResult,
            'confirmationEmail' => true,
        ];

        return new Response(
            content: json_encode($data),
            status: Response::HTTP_OK,
            headers: $headers
        );
    }

    /**
     * Handles disallowed request methods.
     *
     * @return Response contains JSON object with 'message' value
     */
    #[Route('/', condition: "context.getMethod() not in ['POST']")]
    public function handleDisallowedMethods(LoggerInterface $logger): Response
    {
        $logger->debug('Received a request with a disallowed method.');

        return new Response(
            content: json_encode(['message' => 'Only POST requests allowed']),
            status: Response::HTTP_METHOD_NOT_ALLOWED
        );
    }
}

Inquiry.php:

<?php

declare(strict_types=1);

namespace App\Model;

use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * Represents an inquiry received from the front end.
 *
 * The required fields 'subject', 'message', and
 * 'consent' must be provided to the constructor.
 */
class Inquiry
{
    private string $name;
    private string $email;
    private string $phone;
    private string $subject;
    private string $message;
    private bool $consent;

    private bool $validated = false;

    /**
     * @param LoggerInterface    $logger
     * @param MailerInterface    $mailer
     * @param array              $officeRecipients configured in services.yaml
     * @param ValidatorInterface $validator
     */
    public function __construct(
        private readonly LoggerInterface $logger,
        private readonly MailerInterface $mailer,
        private readonly array $officeRecipients,
        private readonly ValidatorInterface $validator,
    ) {
    }

    /**
     * Populates an inquiry with data.
     *
     * 'subject', 'message', and 'consent are required,
     * all other values are optional.
     *
     * Sets 'validated' to false, in case createInquiry()
     * is called multiple times.
     */
    public function createInquiry(
        string $subject,
        string $message,
        bool $consent,
        string $name = '',
        string $email = '',
        string $phone = '',
    ): void {
        $this->subject = $subject;
        $this->message = $message;
        $this->consent = $consent;
        $this->name = $name;
        $this->email = $email;
        $this->phone = $phone;
        $this->validated = false;
    }

    /**
     * Validates the inquiry.
     *
     * If successful, sets 'validated' to true
     *
     * @return ConstraintViolationListInterface if valid: empty
     *                                          if not valid: list of validation violation messages
     */
    public function validateInquiry(): ConstraintViolationListInterface
    {
        $validationResult = $this->validator->validate($this);
        if (0 == count($validationResult)) {
            $this->validated = true;
        }

        return $validationResult;
    }

    /**
     * Sends an email with the customer inquiry data to the office.
     *
     * @return array containing 'success' boolean and 'message' string
     */
    public function sendOfficeEmail(): array
    {
        if (!$this->validated) {
            return [
                'success' => false,
                'message' => 'Inquiry has not been validated. Use Inquiry->validate() first',
            ];
        }

        // convert 'consent' and empty values in to human-readable format
        $plainTextConsent = $this->consent ? 'Ja' : 'Nein';
        $plainTextName = $this->name ?: 'Keine Angabe';
        $plainTextEmail = $this->email ?: 'Keine Angabe';
        $plainTextPhone = $this->phone ?: 'Keine Angabe';

        $emailBody = <<<END
            Das Kontaktformular hat eine Anfrage erhalten.
            
                Betreff: $this->subject
            
                Nachricht: $this->message
            
                Einwilligung: $plainTextConsent
            
                Name: $plainTextName
            
                Email: $plainTextEmail
            
                Telefon: $plainTextPhone
            END;

        $email = (new Email())
            ->to(...$this->officeRecipients)
            ->subject('Anfrage vom Kontaktformular')
            ->text($emailBody);

        try {
            $this->mailer->send($email);
            $this->logger->debug('Email sent');

            return [
                'success' => true,
                'message' => 'Email wurde gesendet',
            ];
        } catch (TransportExceptionInterface $e) {
            $this->logger->debug('Error sending email: '.$e);

            return [
                'success' => false,
                'message' => 'Email konnte nicht gesendet werden: '.$e,
                ];
        }
    }

    /**
     * @codeCoverageIgnore
     */
    public function sendConfirmationEmail(): string
    {
        return '';
    }

    /**
     * Checks whether Inquiry has been validated.
     */
    public function isValidated(): bool
    {
        return $this->validated;
    }
}

Doxyfile (EDITED as per @albert’s comment):

# Difference with default Doxyfile 1.9.3 (c0b9eafbfb53286ce31e75e2b6c976ee4d345473)
PROJECT_NAME           = "Form handler"
PROJECT_BRIEF          = "Stand-alone Symfony backend to handle contact forms."
OUTPUT_DIRECTORY       = ./doc/
INPUT                  = ./src/ \
                         README.md
RECURSIVE              = YES
EXCLUDE                = ./src/Kernel.php
USE_MDFILE_AS_MAINPAGE = README.md
GENERATE_LATEX         = NO

Solution

As of PHP version 7.3.0 the syntax of the here document changed slightly, see https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.heredoc

The closing identifier may be indented by space or tab, in which case the indentation will be stripped from all lines in the doc string. Prior to PHP 7.3.0, the closing identifier must begin in the first column of the line.

This has now been corrected in the proposed patch, pull request: github.com/doxygen/doxygen/pull/9398

Workarounds:

  • place the end identifier of the here document at the beginning of the line
  • place a doxygen conditional block /** \cond / / /* \endcond */ around the here document.

Answered By – albert

This Answer collected from stackoverflow, is licensed under cc by-sa 2.5 , cc by-sa 3.0 and cc by-sa 4.0

Leave a Reply

(*) Required, Your email will not be published