A FAQ for the sites built on omnisense/omnibase:
the questions visitors ask most, sorted into sections, answered in the back office, and a
form for the visitor whose question is missing. It is a Symfony bundle (Base\Faq\FaqBundle)
on the core 3 or 4, through omnibridge/symfony.
It adds no storage of its own kind: a Question is an omnibase Thread (a JOINED
subclass, table faq_question) - the question is the thread's title, the answer its text
(EditorJS or HTML), its state says whether it is online, its likes count the readers it
helped. A Section is an omnibase Taxon, so sections form a tree and a section may hold
sub-sections. What a visitor types in the "A question missing?" form is an Ask, a form
model (not an entity) that implements the core's SpamProtectionInterface: Akismet reads it,
and a question that passes lands as a draft Question in the back office.
What it gives a site:
/faq: every section with its questions folded (<details>), a search, the form to ask a question;/faq/{slug}: one question at its own address with the others of its section. Both pages carry schema.org'sFAQPagein JSON-LD.- Three Twig functions for a host page (
faq_questions(),faq_featured(),faq_sections()) and a partial that draws questions folded. - With omnibase/admin: the
QuestionandSectionscreens, open toROLE_ADMIN, and the dashboard tilefaq_pending_questions; every administrator is told in the app's bell when a visitor asks a question.
Use it when a site needs a handful to a few hundred questions with answers written by its editors. It is not a help desk: there is no conversation with the visitor, who is only told the question arrived (and leaves an e-mail address the editor may write to).
How this manual is organised
- Installation: requirements, the bundle, the routes, the schema, the layout the templates extend.
- Questions and sections: the entities and their fields, the form model, the repository's queries.
- The pages and the form:
/faq, the search, a question's page, asking a question (the guard, Akismet, what is kept), the JSON-LD. - In a host page: the Twig functions, the partial, the stylesheet and its custom properties.
- The back office: the screens, who may write, the dashboard tile, the notification.
- Configuration reference:
faq:, the parameters, the services. - Testing: the suite and what each test checks.
Propose a change docs/index.md
Installation
Requirements
What composer.json requires: PHP 8.2 or later, doctrine/orm 2.15 or 3,
omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin, and
symfony/config, dependency-injection, form, http-kernel and security-bundle 6, 7
or 8.
The core provides what the bundle stands on: Base\Entity\Thread and
Base\Entity\Thread\Taxon (the questions and the sections), the forms' guard
(Base\Service\FormGuard), Akismet through SpamProtectionInterface, the |wysiwyg Twig
filter that renders an answer, the #[Sitemap] attribute, the notifications
(Base\Entity\User\Notification).
The bundle
composer require omnibase/faq:dev-main
// config/bundles.php
return [
// ...
Base\BaseBundle::class => ['all' => true],
Base\Admin\AdminBundle::class => ['all' => true],
Base\Faq\FaqBundle::class => ['all' => true],
];
FaqBundle extends the core's AbstractBaseBundle. Its build() maps the classes of
src/Entity to App\Entity\Faq\* and those of src/Repository to App\Repository\Faq\*
(setMapping()): an application that declares App\Entity\Faq\Question extending
Base\Faq\Entity\Question gets its own class used in place of the bundle's; otherwise the
name is an alias of the bundle's class. getPath() is the package's root, so its
templates/ is the @Faq namespace and its public/ is installed as bundles/faq/.
The extension (Base\Faq\DependencyInjection\FaqExtension) loads config/services.php and
turns the faq: tree into parameters (Configuration reference).
The routes
The public pages are attribute routes in src/Controller/Client:
# config/routes.yaml
faq_controller:
resource: "@FaqBundle/src/Controller/Client"
type: attribute
prefix: /
| Route | Path | Methods | Controller |
|---|---|---|---|
faq_index |
/faq |
GET, POST | FaqController::index() - in the sitemap (priority 0.6, weekly) |
faq_question |
/faq/{slug} (slug: [a-z0-9\-]+) |
any | FaqController::question() |
The back office's screens need no import of their own: omnibase/admin's base_admin loader
generates the routes of every registered CRUD (The back office).
The schema
The bundle maps two entities, faq_question (joined to the core's thread table) and the
Section discriminator entry of the taxon table. Generate and run a migration:
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
The public files
bin/console assets:install
copies public/css/faq.css to public/bundles/faq/css/faq.css, which both pages link.
What the host provides
The two pages extend layout1.html.twig and fill its blocks title, description,
stylesheets (calling parent()) and content. The site's layout must define them.
An answer is printed through the core's |wysiwyg filter (EditorJS JSON or HTML).
Propose a change docs/installation.md
Questions and sections
Question
Base\Faq\Entity\Question extends the core's Base\Entity\Thread (table faq_question,
discriminator faq_question, repository QuestionRepository) and implements
LinkableInterface. What a question uses of the thread:
| Thread field | In the FAQ |
|---|---|
title |
the question (translated, as every thread's title) |
content |
the answer: EditorJS JSON or HTML |
excerpt |
for a visitor's question, the details they added (never shown on the site) |
slug |
the question's address, /faq/{slug} |
state |
ThreadState::PUBLISH online, ThreadState::DRAFT waiting in the back office |
publishedAt |
set the first time it is published (publish()) |
taxa |
holds its Section |
| likes | the readers it helped (the thread's own) |
Its own columns:
| Property | Column | Default | Meaning |
|---|---|---|---|
position |
integer |
0 |
its place in its section, lower first |
featured |
boolean |
false |
on the home page's short FAQ (faq_featured()) |
askedBy |
string(180), nullable |
null |
the e-mail a visitor left when asking it; setAskedBy() trims and lowercases it, an empty value is null |
Methods beyond the accessors:
getSection(): ?Section- the first taxon of the question that is aSection.setSection(?Section)removes everySectionfrom its taxa and adds the one given: a question is in one section at most (other kinds of taxa stay).publish(bool $published = true): self-PUBLISHorDRAFT, andpublishedAtset to now if it was empty.__toLink()- thefaq_questionaddress of its slug;__toString()its title.__iconizeStatic()-fa-solid fa-circle-question.
Section
Base\Faq\Entity\Section extends Base\Entity\Thread\Taxon (discriminator
faq_section): a translated label and description, an icon, a slug, a parent and children
- all the taxon's. It adds:
| Property | Column | Default | Meaning |
|---|---|---|---|
position |
integer |
0 |
its place in the list, lower first |
getPublishedQuestions(): list<Question> returns the published questions among the
threads the section classifies, sorted by position, then by id. Its icon is
fa-solid fa-folder-tree.
Ask
Base\Faq\Entity\Ask is the form's model, not an entity: nothing of it is stored as such.
Public properties and their constraints:
| Property | Type | Constraints |
|---|---|---|
question |
?string |
NotBlank, Length(min: 10, max: 300) |
details |
?string |
Length(max: 2000) |
email |
?string |
Email, Length(max: 180) |
author |
?App\Entity\User |
the signed-in user, when there is one |
spamScore |
?int |
set by Akismet's callback |
As a SpamProtectionInterface it gives Akismet its author (getSpamBlameable()), the
question and details joined (getSpamText()), now (getSpamDate()), and receives the score
(getSpamCallback(int $score)). isDoubtful() is true when the score is at least the
core's SpamScore::MAYBE_SPAM.
The repository
Base\Faq\Repository\QuestionRepository (a ServiceEntityRepository, aliased
App\Repository\Faq\QuestionRepository):
| Method | Returns |
|---|---|
findPublished(?Section $section = null, ?int $limit = null) |
the published questions, by position then id; of one section (through taxa) when given |
findFeatured(int $limit = 4) |
the published questions, featured ones first, then by position and id |
findOnePublished(string $slug) |
the published question of that slug, or null |
search(string $term, int $limit = 20) |
the published questions whose translated title or text contains the term (LIKE %term%) |
findSections() |
the top-level sections (no parent), by position then id, that have a published question or at least one child section |
countDrafts() |
the number of draft questions |
findDrafts(int $limit = 5) |
the oldest drafts first |
A sub-section with no published question is still kept by findSections() through its
parent; the page then shows only the children that have questions.
Propose a change docs/concepts.md
The pages and the form
/faq
FaqController::index() renders @Faq/client/index.html.twig with:
| Variable | Content |
|---|---|
sections |
findSections(): the top-level sections, each drawn with its first per_section published questions, then each child section that has questions under an h3 |
orphans |
the published questions in no section, under "Other questions" (not cut to per_section) |
results |
the search's results when ?q= holds 3 characters or more, null otherwise |
term |
the search term, trimmed |
per_section |
faq.per_section |
ask_form |
the form's view, or null when faq.ask_enabled is false |
jsonld |
the FAQPage of every published question (see below) |
A table of contents of the sections (anchors on each section's slug) shows when there are two sections or more. With no section and no orphan question, the page says "No question published yet.".
The search
GET /faq?q=term: with 3 characters or more, QuestionRepository::search() looks for the
term in the translated titles and texts of the published questions, 20 at most. The results
come first on the page, unfolded (open), under "N answers for "term"". Shorter terms are
ignored.
A question's page
/faq/{slug} renders @Faq/client/question.html.twig: the question as h1, its section as a
breadcrumb, the answer, then the other published questions of its section (siblings). A
slug that matches no published question is a 404. The page's description block is the
question's excerpt.
Asking a question
The form (Base\Faq\Form\AskType, translation domain faq) has three fields: question
(300 characters at most), details (optional, 2000) and email (optional, "to tell you once
the answer is online"). A signed-in visitor's e-mail is filled in and the user kept as the
author. The form posts to /faq#demander.
The guard
The form is guarded as the core guards a form (its guard option; the core's manual,
"The forms' guard"): FaqController passes action: ask, min_delay: faq.min_delay, and
challenge: false when Google reCAPTCHA v3 is on. The guard keeps a trap field, the time
between the form being opened and sent, the core's address lists (base.guard.reputation),
and a captcha when the site has omnisense/omnishield (the core asks it after a number of
refused tries, base.guard.captcha_after). A robot is refused on the form: the page is
rendered again with status 422.
With glitchr/ux-google installed and google.recaptcha.enable on
(Google\Service\GrService::isEnabled()), the form gets a captcha field
(ReCaptchaV3Type) and the guard asks no captcha of its own.
With a core that has no Base\Service\FormGuard (before the forms' guard), AskType adds
its own trap (url, hidden) and a timestamp (opened); the controller refuses a form sent
less than min_delay seconds after it was opened ("A little fast!"), and thanks a filled
trap without keeping anything.
What is kept
A valid form that is not trapped and that Akismet does not doubt (Ask::isDoubtful())
becomes a Question:
- the title is the question, trimmed and cut to 255 characters;
- the excerpt is the details;
askedByis the e-mail;- the state is
DRAFT, the author the signed-in user if any.
It is persisted, and AskNotifier::questionAsked() tells the administrators
(The back office). In every case - kept or not - the
visitor is redirected to /faq#demander with the flash "Thank you! Your question arrived."
(flash bag faq): a doubtful text is thanked and dropped, never queued for moderation.
JSON-LD
Both pages print a <script type="application/ld+json"> from Base\Faq\Service\JsonLd:
/faq with every published question (whatever the search shows), a question's page with
that question only, each with the page's absolute URL.
JsonLd::page(iterable $questions, ?string $url = null): ?array returns
{"@context": "https://schema.org", "@type": "FAQPage", "url": ..., "mainEntity": [...]},
each entry {"@type": "Question", "name": <title>, "acceptedAnswer": {"@type": "Answer", "text": <answer>}}. A question listed twice (same slug) is there once; a question whose
title or answer is empty is left out; with no entry left, page() returns null and no
script is printed.
JsonLd::answer(?string $content): string turns an answer into what search engines read
in one:
- an EditorJS document (JSON starting with
{and holdingblocks) becomes HTML: alistblock a<ul>or<ol>(styleordered), aheaderblock an<h3>, any other block a<p>of itstext; empty blocks are dropped; <script>,<style>,<iframe>and<form>go with their content;- only
<p> <br> <ul> <ol> <li> <a> <strong> <b> <em> <i> <h2> <h3> <h4>stay, without attributes, except a link'shrefwhen it ishttp(s)://,mailto:,tel:, a path or an anchor; - white space is collapsed; tags around nothing give
''.
Run in omnisense/omnibase's harness image, with this checkout's classes on the autoloader:
use Base\Faq\Service\JsonLd;
echo JsonLd::answer(json_encode(['blocks' => [
['type' => 'paragraph', 'data' => ['text' => 'Yes, in your own class.']],
['type' => 'list', 'data' => ['style' => 'unordered', 'items' => ['Print it', 'Project it']]],
]])), "\n";
echo JsonLd::answer('<p class="lead" onclick="x()">See <a href="/terms" target="_blank">the terms</a>.</p><script>alert(1)</script>'), "\n";
var_dump(JsonLd::answer('<p> </p>'));
<p>Yes, in your own class.</p><ul><li>Print it</li><li>Project it</li></ul>
<p>See <a href="/terms">the terms</a>.</p>
string(0) ""
JsonLd is autowired in the container; the controller takes it as an optional argument and
prints no JSON-LD without it.
Propose a change docs/usage-pages.md
In a host page
Twig functions
Base\Faq\Twig\FaqExtension, autoconfigured:
| Function | Returns |
|---|---|
faq_questions(section = null, limit = null) |
QuestionRepository::findPublished(): the published questions, of one Section when given, by position |
faq_featured(limit = 4) |
findFeatured(): the featured questions first, then the others by position |
faq_sections() |
findSections(): the top-level sections that have something to show |
The partial
@Faq/client/_questions.html.twig draws a list of questions as <details> elements, each
with the id q-<slug>, the answer through |wysiwyg and a link to the question's page.
open: true unfolds them.
A short FAQ on a home page:
<section class="faq">
<h2>Questions</h2>
{% include '@Faq/client/_questions.html.twig' with {questions: faq_featured(3)} %}
<a href="{{ path('faq_index') }}">Every question</a>
</section>
<link rel="stylesheet" href="{{ asset('bundles/faq/css/faq.css') }}">
The stylesheet
bundles/faq/css/faq.css styles the .faq block and reads the host's custom properties
when they are set:
| Property | Default | Use |
|---|---|---|
--faq-accent |
#b5411f |
the accent: the buttons, the summary's marker, hovered links |
--faq-soft |
#6b6b6b |
secondary text (eyebrow, leads) |
--faq-line |
rgba(127,127,127,.25) |
borders |
--faq-surface |
rgba(127,127,127,.06) |
the panels' background |
--faq-measure |
52rem |
the block's maximum width |
Templates an application overrides
Every template is in the @Faq namespace: client/index.html.twig,
client/question.html.twig, client/_questions.html.twig,
admin/widget/pending_questions.html.twig. An application overrides one the Symfony way,
in templates/bundles/FaqBundle/<path>.
Translations
The domain faq (ICU message format), in English, French and German
(translations/faq+intl-icu.{en,fr,de}.yaml): the pages' texts (index.*, question.*),
the form (ask.*), the notification (notify.asked) and the back office's labels
(admin.*). The templates and the CRUDs refer to them as @faq.<key>.
Propose a change docs/usage-twig.md
The back office
The screens are loaded only when omnibase/admin is installed
(Base\Admin\Controller\AbstractCrudController exists, config/services.php).
The screens
| CRUD | Entity | Slug | First route |
|---|---|---|---|
Base\Faq\Controller\Admin\Crud\QuestionCrudController |
Question |
questions |
admin_crud_questions_index (/admin/questions) |
Base\Faq\Controller\Admin\Crud\SectionCrudController |
Section |
sections |
admin_crud_sections_index (/admin/sections) |
omnibase/admin's loader gives each the usual routes (_new, _edit, _detail, _delete,
_batchDelete, _toggle...).
QuestionCrudController's fields, in order:
| Field | Type | Notes |
|---|---|---|
id |
IdField |
index only |
title |
TextField |
the question |
state |
StateField |
draft or published |
slug |
SlugField |
follows the title as it is typed; not on the index |
section |
SelectField of Section |
optional |
position |
IntegerField |
|
featured |
BooleanField |
|
excerpt |
TextareaField |
optional: what the visitor added to the question; not shown on the site |
content |
EditorField |
the answer, with collaborative autosave and live editing (collab_autosave, collab_live) |
askedBy |
TextField |
optional: the visitor's e-mail |
Filters: state and featured. A question created in the back office has the signed-in
user as an owner (createEntity()).
SectionCrudController extends omnibase/admin's
Base\Admin\Controller\Crud\Thread\TaxonCrudController (the label, description, icon,
parent... of a taxon) and adds position.
Who may write
omnibase/admin requires the super-administrator for any action that writes, by default.
Both screens open NEW, EDIT, DELETE, BATCH_DELETE and the three save actions to
ROLE_ADMIN (Base\Faq\Controller\Admin\OpenToAdminsTrait::openToAdmins()): a site's
editors answer the questions.
The dashboard tile
Base\Faq\Admin\Widget\PendingQuestionsWidgetType, named faq_pending_questions
(template @Faq/admin/widget/pending_questions.html.twig): the number of draft questions,
which links to the questions' list filtered on drafts, and the five oldest drafts, each
linking to its edit page. With none: "Everything is answered.". A dashboard adds it as any
widget type of omnibase/admin.
The notification
When a visitor's question is kept, Base\Faq\Service\AskNotifier::questionAsked() writes a
Base\Entity\User\Notification for every user whose reachable roles include ROLE_ADMIN
(through the role hierarchy): title and subject "A question asked: "<the first 80
characters>"", the content the details (240 characters) or the title, the URL the question's
edit page (admin_crud_questions_edit). It is sent on the notify channel, the app's bell;
no e-mail is sent.
Propose a change docs/back-office.md
Configuration reference
faq:
Base\Faq\DependencyInjection\FaqConfiguration, dumped with Symfony's
YamlReferenceDumper in omnisense/omnibase's harness image:
faq:
# The form to ask a question that is missing.
ask_enabled: true
# Questions shown per section.
per_section: 20
# Seconds between the form being opened and sent: faster is a robot.
min_delay: 4
| Option | Type | Default | Effect |
|---|---|---|---|
ask_enabled |
bool | true |
false: /faq has no form, and a POST to it only renders the page |
per_section |
int, 1 or more | 20 |
the questions drawn per section (and per child section) on /faq; the questions in no section are not cut |
min_delay |
int, 0 or more | 4 |
given to the forms' guard as min_delay; with a core older than the guard, the controller's own check (0 turns it off) |
Every key is optional; the file can be left out.
Parameters
The extension sets one container parameter per option (the core's
AbstractBaseExtension::setConfiguration()): faq.ask_enabled, faq.per_section,
faq.min_delay. FaqController reads them with #[Autowire('%faq.…%')].
Services
config/services.php autowires and autoconfigures every class of src/, except
DependencyInjection/, Entity/, Controller/Admin/, Admin/ and the bundle class. The
controllers of Controller/Client/ are tagged controller.service_arguments. When
omnibase/admin is installed, Controller/Admin/ (tagged controller.service_arguments)
and Admin/ (the widget type) are loaded too.
| Service | Role |
|---|---|
Base\Faq\Controller\Client\FaqController |
the two pages |
Base\Faq\Repository\QuestionRepository |
the queries (Questions and sections) |
Base\Faq\Form\AskType |
the form |
Base\Faq\Service\JsonLd |
the FAQPage |
Base\Faq\Service\AskNotifier |
the administrators' notification |
Base\Faq\Twig\FaqExtension |
the Twig functions |
Base\Faq\Admin\Widget\PendingQuestionsWidgetType |
the dashboard tile (with omnibase/admin) |
Optional collaborators of the controller: Google\Service\GrService (glitchr/ux-google),
used when present.
Propose a change docs/configuration.md
Testing
The suite
vendor/bin/phpunit # checked out on its own
php vendor/bin/phpunit -c vendor/omnibase/faq/phpunit.xml.dist # from an application that requires it
tests/bootstrap.php registers the test namespace and this checkout's classes on the
autoloader it finds (its own vendor/, the current directory's, or the application's), and
the classes of the omnibase/* packages it requires or suggests, read beside the checkout.
In omnisense/omnibase's harness, with this checkout mounted over the package:
docker run --rm --network none -v "$PWD":/srv/demo/vendor/omnibase/faq:ro omnisense/omnibase-harness test faq
..........S....... 18 / 18 (100%)
OK, but incomplete, skipped, or risky tests!
Tests: 18, Assertions: 38, Skipped: 1.
What each test checks
tests/Form/AskGuardTest.php- the form is guarded by the core's guard instead of its own trap; a question asked as a person asks it goes through; a filled trap, a form sent too fast and a form without the captcha's token are refused; without a captcha the trap and the time alone. The captcha test skips, saying so, where the host asks the captcha only after refused tries (base.guard.captcha_after, 3 by default in the harness).tests/Service/JsonLdTest.php- the questions are aFAQPage; a question without an answer is left out and none gives no page; a question listed twice is there once; an answer keeps paragraphs, lists, links and emphasis only; an EditorJS answer becomes paragraphs and lists; an empty answer.tests/Controller/CrudRequiredFieldsTest.php- on omnibase/admin'sCrudFieldsTestCase: only what a record needs is required, an optional field takes an empty value, one field per property, the slug follows the title.
tests/Fixtures/TestQuestion.php is a Question the tests build without a database.
In a site's tests
A functional test posts the form as a visitor would: open /faq, wait at least
faq.min_delay seconds (or set it to 0 in the test environment), submit with the
guard's fields as rendered. The question is then a draft Question with the e-mail in
askedBy, and each administrator has a Notification.