Skip to content

omnibase/consulting

Consulting for omnibase: an independent professional's services (Offering) presented on their site, the quote request on omnibase's contact model with its notice, kept and answered from the back office; the rules an offering keeps that a site or a profession adds; the hook for hours paid through omnibase/marketplace's Credit.

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/consulting:dev-main

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 than consulting.retention_months, as the form's notice promises.
  • schema.org JSON-LD: a Service per offering, an OfferCatalog on /services.
  • Two Twig functions, consulting_offerings() and consulting_can_pay().
  • Translations in English, French and German (domains consulting and validators).

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 (or consulting.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() is class_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.

Propose a change docs/testing.md