Scaffold Symfony 8 app with Bootstrap/DataTables and Docker stack

- Symfony 8.1 webapp skeleton on PHP 8.5 (AssetMapper/importmap, no Node)
- Form-login gate: User entity, security.yaml, SecurityController,
  app:user:create command, initial migration (user + messenger_messages)
- Bootstrap 5 + DataTables layout (base/login/home templates); any
  table.datatable is auto-initialised
- trusted_proxies/headers wired for the shared Nginx Proxy Manager
- Docker (servers only): docker-compose.yml (php:8.5-fpm-alpine,
  nginx:1.30-alpine, mariadb:12.3; host ports 8085/3310; external
  docker_default network) + docker/{Dockerfile,php.ini,nginx,setup.sh,restart.sh}
- .env with CHANGEME placeholders (no secrets), .gitattributes enforces
  LF so the deploy scripts stay runnable on Linux

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-06 14:11:36 +02:00
co-authored by Claude Sonnet 5
parent 39240a98ec
commit 9f6763040e
74 changed files with 14050 additions and 1 deletions
+36
View File
@@ -0,0 +1,36 @@
/*
* dmtools front-end entrypoint (Symfony AssetMapper / importmap).
* Loaded from base.html.twig via {{ importmap('app') }}.
*/
import './stimulus_bootstrap.js';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'datatables.net-bs5/css/dataTables.bootstrap5.min.css';
import './styles/app.css';
import * as bootstrap from 'bootstrap';
import DataTable from 'datatables.net-bs5';
window.bootstrap = bootstrap;
// Turn any <table class="datatable"> into a DataTable. Opt out per-table with
// data-datatable="false"; pass options as a JSON object in data-datatable-options.
function initDataTables(root = document) {
root.querySelectorAll('table.datatable').forEach((table) => {
if (table.dataset.datatable === 'false' || DataTable.isDataTable(table)) {
return;
}
let options = { pageLength: 25, order: [] };
if (table.dataset.datatableOptions) {
try {
options = { ...options, ...JSON.parse(table.dataset.datatableOptions) };
} catch (e) {
console.error('Invalid data-datatable-options on', table, e);
}
}
new DataTable(table, options);
});
}
document.addEventListener('DOMContentLoaded', () => initDataTables());
document.addEventListener('turbo:load', () => initDataTables());
+18
View File
@@ -0,0 +1,18 @@
{
"controllers": {
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager",
"autoimport": {
"@symfony/ux-turbo/dist/mercure_stream_source_element.js": true
}
},
"mercure-turbo-stream": {
"enabled": false,
"fetch": "eager"
}
}
},
"entrypoints": []
}
@@ -0,0 +1,81 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});
export function generateCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
export function generateCsrfHeaders (formElement) {
const headers = {};
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}
return headers;
}
export function removeCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';
+16
View File
@@ -0,0 +1,16 @@
import { Controller } from '@hotwired/stimulus';
/*
* This is an example Stimulus controller!
*
* Any element with a data-controller="hello" attribute will cause
* this controller to be executed. The name "hello" comes from the filename:
* hello_controller.js -> "hello"
*
* Delete this file or adapt it for your use!
*/
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}
+5
View File
@@ -0,0 +1,5 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
+25
View File
@@ -0,0 +1,25 @@
/* dmtools project styles. Bootstrap + DataTables (bs5) are imported from app.js. */
body {
min-height: 100vh;
}
main.app-main {
padding-top: 1.5rem;
padding-bottom: 3rem;
}
.navbar-brand {
font-weight: 600;
letter-spacing: 0.02em;
}
.login-card {
max-width: 24rem;
margin: 8vh auto 0;
}
/* Let DataTables' controls breathe inside a Bootstrap card. */
table.dataTable {
width: 100% !important;
}