Symfony¶
The nativella/symfony-bundle package runs a Symfony application in Nativella. It registers the native APIs as services, dispatches native events through the event dispatcher, migrates the database when the application starts, and adds a Nativella panel to the profiler.
The Notes demo is a complete Symfony application built with it.
Installation¶
composer require nativella/symfony-bundle
It brings nativella/nativella, the native APIs.
Important
composer require does not enable the bundle, because there is no Flex recipe yet. Add it to config/bundles.php yourself, or the native APIs are not services and no native event reaches your listeners.
return [
// ...
Nativella\Symfony\NativellaBundle::class => ['all' => true],
];
Then run the application, with the nativella command:
nativella run .
It needs nothing else to open in a window. Symfony's front controller, public/index.php, is served as it is.
The APIs are services¶
Every API class is a service named after its class. Inject Nativella\Window, Nativella\Dialog, Nativella\Notification… wherever you need them. Nativella\Nativella holds them all.
use Nativella\Dialog;
use Nativella\Dialog\MessageKind;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
#[Route('/notes/{id}/delete', methods: ['POST'])]
#[IsCsrfTokenValid('delete-note')]
public function delete(Note $note, Dialog $dialog): RedirectResponse
{
if ($dialog->confirm(\sprintf('Delete "%s"?', $note->getTitle()), kind: MessageKind::Warning, okLabel: 'Delete')) {
$this->notes->remove($note);
$this->entityManager->flush();
}
return $this->redirectToRoute('note_index', status: 303);
}
The application runs in a browser too, through a web server or php -S, so protect the forms that change data from other sites as on the web. Here, #[IsCsrfTokenValid] (from symfony/security-bundle) checks the _token field the form sends, {{ csrf_token('delete-note') }}.
Native events are Symfony events¶
The bundle answers POST /_nativella/event before the router and the firewall run, and dispatches the event object through the event dispatcher, named after its class. A listener gets it by type:
Note
Native events are handled before the firewall and the locale listeners, so there is no user (getUser() is null), and translations use the default locale. The session is there, though (the runtime sends its cookie with native events too), so read what you need from it.
namespace App\EventListener;
use Nativella\Event\AppBooted;
use Nativella\Exception\NativeCallException;
use Nativella\GlobalShortcut;
use Nativella\Menu;
use Nativella\Menu\MenuItem;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener]
final readonly class BootListener
{
public function __construct(
private GlobalShortcut $globalShortcut,
private LoggerInterface $logger,
private Menu $menu,
) {
}
public function __invoke(AppBooted $event): void
{
$this->menu->set(
MenuItem::submenu(
'File',
MenuItem::link('note.new', 'New note', '/notes/new', 'CmdOrCtrl+N'),
MenuItem::action('note.import', 'Import…', 'CmdOrCtrl+O'),
MenuItem::separator(),
MenuItem::action('app.quit', 'Quit', 'CmdOrCtrl+Q'),
),
MenuItem::editMenu(),
);
try {
$this->globalShortcut->register('CmdOrCtrl+Shift+N');
} catch (NativeCallException $e) {
// Another application holds it: the application works without.
$this->logger->warning('The global shortcut is not available.', ['exception' => $e]);
}
}
}
namespace App\EventListener;
use Nativella\App;
use Nativella\Event\MenuItemClicked;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener]
final readonly class MenuListener
{
public function __construct(
private App $app,
) {
}
public function __invoke(MenuItemClicked $event): void
{
if ('app.quit' === $event->id) {
$this->app->quit();
}
}
}
Only the runtime reaches that endpoint, because the bundle checks $_SERVER['NATIVELLA_INTERNAL'], which a page cannot set. Every event and its properties are listed in Lifecycle and events.
The database¶
Point Doctrine at the data directory. In .env:
DATABASE_URL="sqlite:///${NATIVELLA_DATA_DIR:-%kernel.project_dir%/var}/app.db"
In Nativella, the database is app.db in the application's data directory; outside it (bin/console, symfony serve, the tests), it falls back to var/app.db.
With doctrine/doctrine-migrations-bundle installed, the bundle runs the pending migrations when the application starts, on AppBooted, before the application's listeners. A desktop application has no deployment step, so every user's database follows the code. Write migrations as usual, with bin/console make:migration or doctrine:migrations:diff. See Database.
The secret¶
Symfony signs and encrypts with framework.secret. Every user gets the same copy of .env, so a secret there would be shared by every installation. The runtime gives each installation its own, as NATIVELLA_SECRET:
parameters:
app.secret: '%env(APP_SECRET)%'
framework:
# Each installation has its own secret in Nativella, APP_SECRET elsewhere.
secret: '%env(default:app.secret:NATIVELLA_SECRET)%'
Configuration¶
nativella:
# Runs the Doctrine migrations when the application starts.
migrate_on_boot: true
when@test:
nativella:
# Answers native calls with Nativella\Client\FakeClient.
fake_client: true
| Option | Default | |
|---|---|---|
migrate_on_boot |
true |
Runs the Doctrine migrations on AppBooted. Needs the DoctrineMigrationsBundle. |
fake_client |
false |
Replaces the client with Nativella\Client\FakeClient. See Testing. |
Twig¶
| Function | |
|---|---|
nativella_is_native() |
Whether native calls work, which means Nativella shows the page or the tests use the fake client. Offer native features only then. |
nativella_is_mobile() |
Whether it runs on a phone or a tablet. |
nativella_platform() |
linux, macos, windows, android, ios, or null outside Nativella. |
{% if nativella_is_native() %}
<form method="post" action="{{ path('note_export', {id: note.id}) }}">
<button>Export…</button>
</form>
{% endif %}
url() and absolute_url() work too. Symfony believes it is served from http://localhost/, and the runtime rewrites those URLs to the window's address in HTML URL attributes and CSS url(). In JSON and scripts, use path(), because only fetch(), XMLHttpRequest and EventSource requests to http://localhost/ are brought back to the application. See Pages and JavaScript.
The profiler¶
In the dev environment, with the WebProfilerBundle, the toolbar gets a Nativella panel that lists every native call of the request, with its payload, its result or its error, and its duration.
Profiles are written to disk, so the values of secrets.* calls show as ******.
Testing¶
With fake_client: true in the test environment, the container holds a Nativella\Client\FakeClient instead of the real client, and the application behaves as in Nativella. isNative() on the Nativella\Nativella service and nativella_is_native() are true. Tests fetch the fake to queue answers and check the calls:
use Nativella\Client\FakeClient;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class NoteControllerTest extends WebTestCase
{
public function testDeletingANoteAsksFirst(): void
{
$client = static::createClient();
$native = static::getContainer()->get(FakeClient::class);
$native->respond('dialog.confirm', ['confirmed' => true]);
// From the page, as the user does: the form carries its CSRF token.
$client->request('GET', '/notes/1');
$client->submitForm('Delete');
$this->assertResponseRedirects('/notes', 303);
$this->assertCount(1, $native->calls('dialog.confirm'));
}
}
Testing explains the fake client and its default answers.
Running in a browser¶
The application still runs with symfony serve or any web server, which is handy for working on templates. The native APIs throw a RuntimeUnavailableException there. Guard what must keep working with isNative() on the Nativella\Nativella service in PHP, and nativella_is_native() in Twig. Both are true under the fake client, unlike Nativella\Runtime::isNative(), so the tests go through the native code.