omnibase/consulting is the services page of a trainer, a speaker, a consultant
or a writer: what is offered, a page per service, and a quote request that is
kept for the back office and sent to the site's address. It is a Symfony bundle
of the omnibase family (namespace Base\Consulting\), built on
omnisense/omnibase and omnibase/admin.
What it adds
- Two entities:
Offering(consulting_offering), a service with its activity, summary, description, audience, format, duration, price and picture;QuoteRequest(consulting_quote_request), what a visitor asked, with its status and the back office's notes. - Three routes:
/services(the list, in the sitemap),/services/{slug}(a service) and/quote(the request form,?offering=<slug>to name the service). - The quote form: omnisense/omnibase's contact form with what a quote needs, guarded (trap, timing, captcha when omnisense/omnishield is installed) and closed by a consent to the notice.
- A tag,
consulting.offering_rule, for the rules an offering keeps: the bundle refuses nothing by itself, a site or a profession package adds its rules. - Two back office screens (offerings, quote requests) open to the site's administrator, and a dashboard tile counting the new requests.
consulting:purge, which deletes the requests older thanconsulting.retention_months, as the form's notice promises.- schema.org JSON-LD: a
Serviceper offering, anOfferCatalogon/services. - Two Twig functions,
consulting_offerings()andconsulting_can_pay(). - Translations in English, French and German (domains
consultingandvalidators).
What it leaves to others
Hours paid in advance belong to omnibase/marketplace's generic Credit: until
that class exists, no offering is sold by the hour (Hours paid in
advance). A jurist's guard on legal advice is omnibase/legal's
(legal.profession: jurist); the two former options that held it here are
deprecated and refused when set (Configuration reference).
Pages
Propose a change docs/index.md
Installation and configuration
// config/bundles.php
Base\Consulting\ConsultingBundle::class => ['all' => true],
# config/routes.yaml
consulting_controller:
resource: "@ConsultingBundle/src/Controller/Client"
type: attribute
prefix: /
# config/packages/consulting.yaml (every key optional)
consulting:
recipient: '%env(MAILER_CONTACT)%' # where a quote request is sent
provider: ~ # who provides the services (schema.org); ~: base.settings.title
retention_months: 36 # how long a request is kept (the notice says so)
bin/console doctrine:migrations:diff && bin/console doctrine:migrations:migrate # consulting_offering, consulting_quote_request
bin/console assets:install # public/css/consulting.css
10 4 * * * php /srv/app/bin/console consulting:purge >> /srv/app/var/log/cron.consulting-purge.log 2>&1
// DashboardController
yield MenuItem::block('consulting_quotes', 'Demandes de devis', 'fa-solid fa-file-signature')->setSize(2);
yield MenuItem::linkToCrud(\Base\Consulting\Entity\Offering::class, 'Prestations', 'fa-solid fa-handshake');
yield MenuItem::linkToCrud(\Base\Consulting\Entity\QuoteRequest::class, 'Demandes de devis', 'fa-solid fa-file-signature');
The templates extend layout1.html.twig (title, description,
stylesheets, content); consulting.css follows --consulting-accent,
--consulting-on-accent, --consulting-ink, --consulting-soft,
--consulting-line, --consulting-surface, --consulting-font-display,
--consulting-measure.
Requirements
- PHP 8.2 or later;
- doctrine/orm 2.15 or 3;
- omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin;
- Symfony 6.4, 7 or 8 (config, dependency-injection, form, http-kernel, mailer, string, validator);
- a mailer DSN, and
MAILER_CONTACT(orconsulting.recipient) for the address the requests go to.
Suggested: omnibase/marketplace, for hours paid in advance once it has
Credit (Hours paid in advance); omnisense/omnishield, for the
captcha of the quote form.
Propose a change docs/installation.md
Concepts
An offering
An Offering is one service as the site prints it. Its title gives its slug
(ASCII, lower case, at most 120 characters) unless a slug is set; its
activity is one case of Base\Consulting\Enum\Activity:
| Case | Value |
|---|---|
TRAINING |
training (the default) |
TALK |
talk |
CONSULTING |
consulting |
INFORMATION |
information |
WRITING |
writing |
OTHER |
other |
setActivity() takes a case or its value; an unknown value becomes OTHER.
The price is a string printed as written ("On quote", "450 € the day"), not an
amount: the bundle sells nothing. hours is the size of a package of hours,
read only when omnibase/marketplace can sell one.
The description is the back office's editor output or plain text:
isHtml() says which (it looks for p, ul, ol, h1-h6, div, br,
strong, em, a tags), and the page prints one or the other. getText()
joins the title, the summary and the description without tags: it is what the
offering rules read.
An offering appears on the site when visible is true, in the order of
position. quotable says whether the quote form lists it.
A quote request
A QuoteRequest is what a visitor sent through /quote: name, e-mail, phone,
organisation, number of participants, dates, location, budget, message, the
locale of the page, the offering it named (set to null when that offering is
deleted) and the moment it was sent. Its status is a case of
Base\Consulting\Enum\QuoteStatus: new, answered, archived. The back
office adds its own notes.
The form does not write the entity: it fills a model,
Base\Consulting\Form\Model\QuoteRequestModel (omnisense/omnibase's
ContactModel with the quote's fields and the consent), and
toQuoteRequest($locale) builds the entity once the form is valid. A robot
refused by the guard leaves nothing in the database.
This example builds an offering and a request from its model, outside any host:
use Base\Consulting\Entity\Offering;
use Base\Consulting\Enum\Activity;
use Base\Consulting\Form\Model\QuoteRequestModel;
$offering = (new Offering('Atelier : enseigner l’allemand au CP', Activity::TRAINING))
->setSummary('Une demi-journée pour les enseignants.')
->setDescription('<p>Jeux, chansons et <strong>rituels</strong>.</p>')
->setActivity('legal_consultation');
echo $offering->getSlug(), ' | ', $offering->getActivity()->value, ' | html: ', var_export($offering->isHtml(), true), "\n";
echo str_replace("\n", ' / ', $offering->getText()), "\n";
$model = new QuoteRequestModel();
$model->name = ' Anna Keller ';
$model->email = 'anna@example.org';
$model->message = 'Pour 30 enseignants, en mars.';
$model->offering = $offering;
$model->participants = '30';
$quote = $model->toQuoteRequest('fr_FR');
echo $quote, ' | ', $quote->getStatus()->value, ' | ', $quote->getLocale(), ' | ', $quote->getOffering()?->getTitle(), "\n";
Its output, run in omnisense/omnibase-harness:
atelier-enseigner-l-allemand-au-cp | other | html: true
Atelier : enseigner l’allemand au CP / Une demi-journée pour les enseignants. / Jeux, chansons et rituels.
Anna Keller | new | fr_FR | Atelier : enseigner l’allemand au CP
The activity legal_consultation is not one of the cases, so the offering is
other; the name is trimmed; a new request is new.
Keeping and forgetting
The notice under the form says what is kept, why, for how long
(consulting.retention_months, 36 by default) and how to have it removed.
consulting:purge deletes the requests sent before that many months ago; it
runs from cron, once a day (Installation and configuration).
Propose a change docs/concepts.md
Offerings and quote requests
Offering
title, slug, activity (Enum\Activity: training, talk, consulting,
information, writing, other), summary,
description (the back office's editor, or plain text), audience,
format, duration, price (as printed: "On quote"), hours (a package
sold by the hour, once the marketplace does), languages, image,
quotable, position, visible.
Twig: consulting_offerings(n) for a home page,
consulting_can_pay(offering). {% include '@Consulting/client/_offering.html.twig' %}
renders a card. JSON-LD: a Service per offering, an OfferCatalog on
/services.
The quote request
Form\Type\QuoteRequestType is omnibase's Base\Form\Type\ContactType
(name, e-mail, phone, message) with the offering, the organisation, the
participants, the dates, the place, the budget, and the consent to the
notice; its model Form\Model\QuoteRequestModel extends
Base\Form\Model\ContactModel.
It is guarded as omnisense/omnibase guards a form - the option guard
(action: quote; omnisense/omnibase's docs/20-architecture/guard.md): a
trap, the time it takes (a signed stamp), the lists of base.guard.reputation,
the captcha when the site has omnisense/omnishield. Without omnishield, the trap
and the time alone. A robot is refused on the form, before anything is kept.
$form = $forms->createNamed('quote', QuoteRequestType::class, $model = new QuoteRequestModel());
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($model->toQuoteRequest($request->getLocale()));
}
The page keeps the request (QuoteRequest: new, answered, archived, the
back office's notes) and mails it to consulting.recipient, its reply-to
the visitor. The notice under the form says what is kept, why, for how long
(retention_months) and how to have it removed; consulting:purge keeps
that promise.
Who writes in the back office
The two screens (OfferingCrudController, QuoteRequestCrudController) are
the site's administrator's (ROLE_ADMIN: the professional whose site it
is), not the super-admin's only: they carry omnibase/admin's
#[OpenToAdmins] - creating, editing and deleting a service; annotating,
deleting a quote request and its two buttons, answered and archive. The
attribute is omnibase/admin's from 7474f85.
The pages
| Route | Path | Methods | Template | What it does |
|---|---|---|---|---|
consulting_offerings |
/services |
GET | @Consulting/client/offerings.html.twig |
the visible offerings by position, with an OfferCatalog; in the sitemap (priority 0.8, monthly) |
consulting_offering |
/services/{slug} |
GET | @Consulting/client/offering.html.twig |
one visible offering (slug [a-z0-9-]+), its Service, the other offerings; 404 for an unknown or hidden slug |
consulting_quote |
/quote |
GET, POST | @Consulting/client/quote.html.twig |
the form; ?offering=<slug> selects a visible offering |
The quote page answers 200 with an empty form, 422 when a submitted form is
invalid (a guard's refusal included), and, once valid, keeps the request,
sends the mail and renders @Consulting/client/quote_sent.html.twig with the
model. The mail's subject is quote.mail.subject (domain consulting, with
the visitor's name and the offering's title, or "—"), its body
@Consulting/email/quote.html.twig with the request.
The routes are imported from the controller's directory (Installation and configuration); a site that wants other paths imports them with a prefix or declares its own routes to the same controller.
Overriding the templates
The templates are Twig's @Consulting namespace: a site overrides one in
templates/bundles/ConsultingBundle/ (for instance
templates/bundles/ConsultingBundle/client/_offering.html.twig). The partial
@Consulting/client/_jsonld.html.twig prints the JSON-LD a page passes as
jsonld.
Propose a change docs/offerings-and-quotes.md
Rules an offering keeps
The bundle refuses nothing by itself: what an offering may say is the site's
business, or its profession's. A rule is a service implementing
Base\Consulting\Offering\OfferingRuleInterface (autoconfigured, tag
consulting.offering_rule); the #[OfferingRules] constraint on Offering
asks every rule before an offering is saved, and the back office's form
prints the reason under the field the rule names.
final class NoPricesRule implements OfferingRuleInterface
{
public function check(Offering $offering): ?OfferingViolation
{
return null !== $offering->getPrice() ? new OfferingViolation('No price on this site.', 'price') : null;
}
}
A jurist's site
The guard on what French law no. 71-1130 of 31 December 1971 reserves to the
regulated professions (personalised legal advice, deeds drafted for others)
was this bundle's RegulatedActivityGuard until 2026-10-09. It is now the
jurist's profile of omnibase/legal:
# config/packages/legal.yaml
legal:
profession: jurist
jurist:
authorized_status: ~ # teacher (art. 57), regulated_profession (art. 59)
With omnibase/consulting installed, its rule refuses an offering whose title,
summary or presentation announces a legal consultation or the drafting of
deeds. consulting.regulated_activity_guard and consulting.authorized_status
are read for one version: off, they change nothing (and say they are
deprecated); on, they say where the guard went. The activities
legal_consultation and deed_drafting are gone from Enum\Activity: a
stored one reads as other.
Propose a change docs/offering-rules.md
Hours paid in advance
The plan of the omnibase family gives hours paid in advance to
omnibase/marketplace's generic Credit (with Entitlement, Usage),
written with the events platform. This bundle writes no hours entity of its
own and copies nothing of forge's HourCredit.
What is here:
Offering::$hours: the package an offering would be sold as;Service\CreditBridge:isAvailable()isclass_exists('Base\Marketplace\Entity\Credit'),canSell($offering)is that and a package of hours;- the offering page shows the package only when
canSell()says so.
When the marketplace has Credit, the bridge is where the product is made
and the hours counted down.
Propose a change docs/credits.md
The back office
The back office screens are loaded when omnibase/admin is installed (its
Base\Admin\Controller\AbstractCrudController exists). They carry
omnibase/admin's #[OpenToAdmins], so the site's administrator (ROLE_ADMIN)
writes in them, not the super-admin only; any other user is refused.
Offerings
Base\Consulting\Controller\Admin\Crud\OfferingCrudController creates, edits
and deletes an offering. Its fields, in order: title, activity (the enum's
cases, translated), slug (following the title), summary, description (the
editor), audience, format, duration, price (with a help text: it is printed as
written), hours (with a help text: read once the marketplace sells hours),
languages, image, position, quotable, visible.
Quote requests
Base\Consulting\Controller\Admin\Crud\QuoteRequestCrudController reads the
requests. There is no "new" action: a request comes from the site's form. What
the visitor wrote (date, name, e-mail, phone, organisation, participants,
dates, location, budget, message) is shown disabled; the status, the offering
and the notes can be changed.
Two actions, on the index and on the detail page, set the status:
| Action | Path under the screen | Status set |
|---|---|---|
answered |
/{entityId}/answered |
answered |
archive |
/{entityId}/archive |
archived |
Both are open to the administrator (#[OpenToAdmins(actions: ['answered', 'archive'])]). Each flashes admin.quote.flash.<status> (domain
consulting, with the visitor's name) and goes back to the page it came from
when the referer is on the same host, to the index otherwise.
The dashboard tile
Base\Consulting\Admin\Widget\QuoteRequestsWidgetType is the widget
consulting_quotes: the number of new requests (countNew()) and the five
latest (findNew(5)), rendered by
@Consulting/admin/widget/quotes.html.twig. A dashboard adds it as a block:
yield MenuItem::block('consulting_quotes', 'Demandes de devis', 'fa-solid fa-file-signature')->setSize(2);
Propose a change docs/back-office.md
Configuration reference
The consulting tree
Dumped with Symfony's YamlReferenceDumper from
Base\Consulting\DependencyInjection\ConsultingConfiguration:
consulting:
# Where a quote request is sent.
recipient: '%env(MAILER_CONTACT)%'
# Who provides the services, for schema.org (a name); null: the site's title.
provider: null
regulated_activity_guard: false # Deprecated (Since omnibase/consulting 1.1: The "regulated_activity_guard" option is gone: a jurist's guard on legal advice is omnibase/legal's (legal.profession: jurist). Remove it.)
authorized_status: null # Deprecated (Since omnibase/consulting 1.1: The "authorized_status" option is gone: it is legal.jurist.authorized_status in omnibase/legal. Remove it.)
# How long a quote request is kept, as the form's notice says.
retention_months: 36
| Key | Type | Default | Read by |
|---|---|---|---|
recipient |
string | %env(MAILER_CONTACT)% |
the quote page: the request's mail goes there, its reply-to the visitor |
provider |
string or null | null | Service\JsonLd: the provider of each Service and the name of the OfferCatalog; null takes the setting base.settings.title |
retention_months |
integer, at least 1 | 36 | the form's notice and consulting:purge |
regulated_activity_guard |
boolean | false | nothing: deprecated |
authorized_status |
string or null | null | nothing: deprecated |
The two deprecated keys are refused when set: regulated_activity_guard: true
fails with "consulting.regulated_activity_guard: the guard on what the law of
31 December 1971 reserves moved to omnibase/legal - install it with
legal.profession: jurist, and remove this option.", and any
authorized_status fails with "consulting.authorized_status moved to
omnibase/legal: legal.jurist.authorized_status.".
Parameters
Each key is also a container parameter: %consulting.recipient%,
%consulting.provider%, %consulting.retention_months%.
Services
config/services.php autowires and autoconfigures everything under
Base\Consulting\ except the DI classes, entities, enums, exceptions, form
models and Offering\OfferingViolation. The services a site meets:
| Service | Role |
|---|---|
Base\Consulting\Repository\OfferingRepository |
findVisible(?int $limit), findOneVisible(string $slug) |
Base\Consulting\Repository\QuoteRequestRepository |
findNew(?int $limit), countNew(), purgeOlderThan(\DateTimeImmutable $before) |
Base\Consulting\Service\JsonLd |
service(Offering), catalog(list<Offering>) |
Base\Consulting\Service\CreditBridge |
isAvailable(), canSell(Offering) |
Base\Consulting\Validator\OfferingRulesValidator |
asks every consulting.offering_rule service |
Base\Consulting\Twig\ConsultingExtension |
consulting_offerings(limit), consulting_can_pay(offering) |
Base\Consulting\Command\PurgeCommand |
consulting:purge |
consulting_offerings() returns an empty list when the database cannot be
read (a page that includes it still renders before the migration ran).
Translations
Domain consulting (activity, offerings, offering, quote, admin) and
domain validators (quote.consent_required), in English, French and German,
ICU message format. A site overrides a message in its own
translations/consulting+intl-icu.<locale>.yaml.
Propose a change docs/configuration.md
Testing
Running the suite
From the package's checkout, with its dev dependencies:
vendor/bin/phpunit
Inside a host application:
php vendor/bin/phpunit -c vendor/omnibase/consulting/phpunit.xml.dist
Or in omnisense/omnibase-harness, mounting the checkout in place of the installed package:
docker run --rm --network none \
-v "$PWD":/srv/demo/vendor/omnibase/consulting:ro \
omnisense/omnibase-harness:latest test consulting
On this checkout, in the harness:
Tests: 25, Assertions: 89, Skipped: 1.
The skipped test is the captcha's: the harness asks the captcha only after
refused tries (base.guard.captcha_after), so a first request without its
token is not refused there.
What the tests check
| Test | Checks |
|---|---|
Controller\Admin\OpenToAdminsTest |
the administrator writes in both screens; any other user is refused (needs a host kernel and an omnibase/admin with #[OpenToAdmins]) |
Controller\CrudRequiredFieldsTest |
omnibase/admin's CrudFieldsTestCase on the two screens: a field is required exactly when the record needs it, an optional field takes an empty value, one field per property, the slug follows a field |
Entity\EnumFieldsTest |
an offering holds its activity, a request its status; the columns are strings of the enum's length; each case names itself in the consulting domain |
Form\QuoteRequestGuardTest |
the form is guarded in place of its own trap; a request sent as a person goes through; a filled trap, a request sent too fast, a request without the captcha's token (when there is one) are refused; without a captcha, the trap and the time alone |
Service\CreditBridgeTest |
no hours are sold before the marketplace has Credit |
Validator\OfferingRulesTest |
without rules every offering validates; a rule refuses under the field it names; every rule is asked; no activity is reserved here |
A test that needs a host (its kernel) is skipped when run from a bare checkout.