From f9e70e96f1a8f198aafa9f9d9d4c52977afcc7b0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 09:16:12 +0000
Subject: [PATCH 1/6] Initial plan
From ae32fcf14b3d4cca8f4d74e5cde25cb0210fe4de Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 09:28:49 +0000
Subject: [PATCH 2/6] Add AbstractController with Symfony-compatible methods
Co-authored-by: gabrielsolomon <17990591+gabrielsolomon@users.noreply.github.com>
---
src/AbstractController.php | 350 +++++++++++++++++++++++++++
tests/src/AbstractControllerTest.php | 125 ++++++++++
2 files changed, 475 insertions(+)
create mode 100644 src/AbstractController.php
create mode 100644 tests/src/AbstractControllerTest.php
diff --git a/src/AbstractController.php b/src/AbstractController.php
new file mode 100644
index 0000000..9956000
--- /dev/null
+++ b/src/AbstractController.php
@@ -0,0 +1,350 @@
+getResponseFactory()->json($data, $status, $headers);
+ }
+
+ /**
+ * Renders a view and returns a Response object.
+ *
+ * @param string $view The view name
+ * @param array $parameters An array of parameters to pass to the view
+ * @param Response|null $response A response instance
+ *
+ * @return Response
+ */
+ protected function render(
+ string $view,
+ array $parameters = [],
+ ?Response $response = null
+ ): Response {
+ $response = $response ?? new Response();
+
+ // Set view parameters on payload
+ $this->payload()->with($parameters);
+
+ // Load the view content
+ $content = $this->renderView($view, $parameters);
+
+ $response->setContent($content);
+
+ return $response;
+ }
+
+ /**
+ * Renders a view and returns the rendered content as a string.
+ *
+ * @param string $view The view name
+ * @param array $parameters An array of parameters to pass to the view
+ *
+ * @return string
+ */
+ protected function renderView(string $view, array $parameters = []): string
+ {
+ // Set view parameters
+ $viewObj = $this->getView();
+ foreach ($parameters as $key => $value) {
+ $viewObj->set($key, $value);
+ }
+
+ // Load the view and return content
+ return $this->loadView(true);
+ }
+
+ /**
+ * Returns a RedirectResponse to the given URL.
+ *
+ * @param string $url The URL to redirect to
+ * @param int $status The HTTP status code (302 "Found" by default)
+ *
+ * @return RedirectResponse
+ */
+ protected function redirectResponse(string $url, int $status = 302): RedirectResponse
+ {
+ return new RedirectResponse($url, $status);
+ }
+
+ /**
+ * Returns a RedirectResponse to the given route with the given parameters.
+ *
+ * @param string $route The name of the route
+ * @param array $parameters An array of parameters
+ * @param int $status The HTTP status code (302 "Found" by default)
+ *
+ * @return RedirectResponse
+ */
+ protected function redirectToRoute(
+ string $route,
+ array $parameters = [],
+ int $status = 302
+ ): RedirectResponse {
+ $url = $this->generateUrl($route, $parameters);
+
+ return new RedirectResponse($url, $status);
+ }
+
+ /**
+ * Generates a URL from the given parameters.
+ *
+ * @param string $route The name of the route
+ * @param array $parameters An array of parameters
+ *
+ * @return string The generated URL
+ */
+ protected function generateUrl(string $route, array $parameters = []): string
+ {
+ // Check if router service is available
+ if (function_exists('app') && app()->has('router')) {
+ return app('router')->generate($route, $parameters);
+ }
+
+ // Fallback: construct basic URL
+ $url = '/' . $route;
+ if (!empty($parameters)) {
+ $url .= '?' . http_build_query($parameters);
+ }
+
+ return $url;
+ }
+
+ /**
+ * Adds a flash message to the current session for type.
+ *
+ * @param string $type The type (e.g., 'success', 'error', 'info')
+ * @param mixed $message The flash message
+ *
+ * @return void
+ */
+ protected function addFlash(string $type, mixed $message): void
+ {
+ if (function_exists('app') && app()->has('flash.messages')) {
+ app('flash.messages')->add($this->getName(), $type, $message);
+ }
+ }
+
+ /**
+ * Checks if the attribute is granted against the current authentication token and optionally supplied subject.
+ *
+ * @param mixed $attribute A single attribute or an array of attributes
+ * @param mixed $subject The subject to secure
+ *
+ * @return bool
+ *
+ * @throws \LogicException
+ */
+ protected function isGranted(mixed $attribute, mixed $subject = null): bool
+ {
+ if (!function_exists('app') || !app()->has('security.authorization_checker')) {
+ throw new \LogicException('The SecurityBundle is not registered in your application. Try running "composer require symfony/security-bundle".');
+ }
+
+ return app('security.authorization_checker')->isGranted($attribute, $subject);
+ }
+
+ /**
+ * Throws an exception unless the attribute is granted against the current authentication token and optionally
+ * supplied subject.
+ *
+ * @param mixed $attribute A single attribute or an array of attributes
+ * @param mixed $subject The subject to secure
+ * @param string $message The message passed to the exception
+ *
+ * @return void
+ *
+ * @throws AccessDeniedException
+ */
+ protected function denyAccessUnlessGranted(
+ mixed $attribute,
+ mixed $subject = null,
+ string $message = 'Access Denied.'
+ ): void {
+ if (!$this->isGranted($attribute, $subject)) {
+ $exception = $this->createAccessDeniedException($message);
+ if (method_exists($exception, 'setAttributes')) {
+ $exception->setAttributes([$attribute]);
+ }
+ if (method_exists($exception, 'setSubject')) {
+ $exception->setSubject($subject);
+ }
+
+ throw $exception;
+ }
+ }
+
+ /**
+ * Get a user from the Security Token Storage.
+ *
+ * @return mixed|null
+ *
+ * @throws \LogicException If SecurityBundle is not available
+ */
+ protected function getUser(): mixed
+ {
+ if (!function_exists('app') || !app()->has('security.token_storage')) {
+ throw new \LogicException('The SecurityBundle is not registered in your application. Try running "composer require symfony/security-bundle".');
+ }
+
+ $token = app('security.token_storage')->getToken();
+ if (null === $token) {
+ return null;
+ }
+
+ return $token->getUser();
+ }
+
+ /**
+ * Gets a container parameter by its name.
+ *
+ * @param string $name The parameter name
+ *
+ * @return array|bool|string|int|float|\UnitEnum|null
+ *
+ * @throws \LogicException
+ */
+ protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null
+ {
+ if (function_exists('config')) {
+ return config($name);
+ }
+
+ if (function_exists('app') && app()->has('config')) {
+ return app('config')->get($name);
+ }
+
+ throw new \LogicException('Configuration service is not available.');
+ }
+
+ /**
+ * Returns a BinaryFileResponse object with original or customized file name and disposition header.
+ *
+ * @param \SplFileInfo|string $file File object or path to file to be sent as response
+ * @param string|null $fileName Override the file name
+ * @param string $disposition One of "attachment" or "inline"
+ *
+ * @return BinaryFileResponse
+ */
+ protected function file(
+ \SplFileInfo|string $file,
+ ?string $fileName = null,
+ string $disposition = ResponseHeaderBag::DISPOSITION_ATTACHMENT
+ ): BinaryFileResponse {
+ return $this->getResponseFactory()->file($file, $fileName, $disposition);
+ }
+
+ /**
+ * Streams a view.
+ *
+ * @param \Closure $callback The callback to execute
+ * @param int $status The HTTP status code
+ * @param array $headers An array of response headers
+ *
+ * @return StreamedResponse
+ */
+ protected function stream(
+ \Closure $callback,
+ int $status = 200,
+ array $headers = []
+ ): StreamedResponse {
+ return $this->getResponseFactory()->stream($callback, $status, $headers);
+ }
+
+ /**
+ * Checks the validity of a CSRF token.
+ *
+ * @param string $id The id used when generating the token
+ * @param string|null $token The actual token sent with the request that should be validated
+ *
+ * @return bool
+ *
+ * @throws \LogicException
+ */
+ protected function isCsrfTokenValid(string $id, ?string $token): bool
+ {
+ if (!function_exists('app') || !app()->has('security.csrf.token_manager')) {
+ throw new \LogicException('CSRF protection is not enabled in your application. Enable it with the "csrf_protection" key in "config/packages/framework.yaml".');
+ }
+
+ return app('security.csrf.token_manager')->isTokenValid(
+ new \Symfony\Component\Security\Csrf\CsrfToken($id, $token)
+ );
+ }
+
+ /**
+ * Creates and returns a Form instance from the type of the form.
+ *
+ * @param string $type The fully qualified class name of the form type
+ * @param mixed $data The initial data for the form
+ * @param array $options An array of options
+ *
+ * @return mixed
+ *
+ * @throws \LogicException
+ */
+ protected function createForm(string $type, mixed $data = null, array $options = []): mixed
+ {
+ if (!function_exists('app') || !app()->has('form.factory')) {
+ throw new \LogicException('Forms are not enabled in your application. Try running "composer require symfony/form".');
+ }
+
+ return app('form.factory')->create($type, $data, $options);
+ }
+
+ /**
+ * Creates and returns a form builder instance.
+ *
+ * @param mixed $data The initial data for the form
+ * @param array $options An array of options
+ *
+ * @return mixed
+ *
+ * @throws \LogicException
+ */
+ protected function createFormBuilder(mixed $data = null, array $options = []): mixed
+ {
+ if (!function_exists('app') || !app()->has('form.factory')) {
+ throw new \LogicException('Forms are not enabled in your application. Try running "composer require symfony/form".');
+ }
+
+ return app('form.factory')->createBuilder(
+ \Symfony\Component\Form\Extension\Core\Type\FormType::class,
+ $data,
+ $options
+ );
+ }
+}
diff --git a/tests/src/AbstractControllerTest.php b/tests/src/AbstractControllerTest.php
new file mode 100644
index 0000000..0cc88c6
--- /dev/null
+++ b/tests/src/AbstractControllerTest.php
@@ -0,0 +1,125 @@
+json(['status' => 'success', 'data' => 'test']);
+ }
+ };
+
+ $response = $controller->testAction();
+
+ self::assertInstanceOf(JsonResponse::class, $response);
+ $content = $response->getContent();
+ self::assertStringContainsString('"status":"success"', $content);
+ self::assertStringContainsString('"data":"test"', $content);
+ }
+
+ public function testJsonResponseWithCustomStatus()
+ {
+ $controller = new class extends AbstractController {
+ public function testAction()
+ {
+ return $this->json(['error' => 'not found'], 404);
+ }
+ };
+
+ $response = $controller->testAction();
+
+ self::assertInstanceOf(JsonResponse::class, $response);
+ self::assertEquals(404, $response->getStatusCode());
+ }
+
+ public function testRedirectResponse()
+ {
+ $controller = new class extends AbstractController {
+ public function testAction()
+ {
+ return $this->redirectResponse('/test-url');
+ }
+ };
+
+ $response = $controller->testAction();
+
+ self::assertInstanceOf(RedirectResponse::class, $response);
+ self::assertEquals('/test-url', $response->getTargetUrl());
+ self::assertEquals(302, $response->getStatusCode());
+ }
+
+ public function testRedirectResponseWithCustomStatus()
+ {
+ $controller = new class extends AbstractController {
+ public function testAction()
+ {
+ return $this->redirectResponse('/test-url', 301);
+ }
+ };
+
+ $response = $controller->testAction();
+
+ self::assertInstanceOf(RedirectResponse::class, $response);
+ self::assertEquals(301, $response->getStatusCode());
+ }
+
+ public function testCreateNotFoundException()
+ {
+ $controller = new class extends AbstractController {
+ public function testAction()
+ {
+ throw $this->createNotFoundException('Test not found');
+ }
+ };
+
+ $this->expectException(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);
+ $this->expectExceptionMessage('Test not found');
+
+ $controller->testAction();
+ }
+
+ public function testCreateAccessDeniedException()
+ {
+ $controller = new class extends AbstractController {
+ public function testAction()
+ {
+ throw $this->createAccessDeniedException('Access denied test');
+ }
+ };
+
+ $this->expectException(\Symfony\Component\Security\Core\Exception\AccessDeniedException::class);
+ $this->expectExceptionMessage('Access denied test');
+
+ $controller->testAction();
+ }
+
+ public function testGenerateUrlBasic()
+ {
+ $controller = new class extends AbstractController {
+ public function testAction()
+ {
+ return $this->generateUrl('test-route', ['id' => 123]);
+ }
+ };
+
+ $url = $controller->testAction();
+
+ // Without router service, should fallback to basic URL construction
+ self::assertStringContainsString('test-route', $url);
+ self::assertStringContainsString('id=123', $url);
+ }
+}
From e1edcf905d5043085a7ecd28e9fda180b46f3eb7 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 09:30:00 +0000
Subject: [PATCH 3/6] Add Symfony compatibility documentation
Co-authored-by: gabrielsolomon <17990591+gabrielsolomon@users.noreply.github.com>
---
README.md | 10 ++
SYMFONY_COMPATIBILITY.md | 306 +++++++++++++++++++++++++++++++++++++++
2 files changed, 316 insertions(+)
create mode 100644 SYMFONY_COMPATIBILITY.md
diff --git a/README.md b/README.md
index 5d1042b..066b465 100644
--- a/README.md
+++ b/README.md
@@ -11,3 +11,13 @@ ByTIC Controller component
[](https://scrutinizer-ci.com/g/bytic/controllers)
[](https://styleci.io/repos/119902214)
[](https://packagist.org/packages/bytic/controllers)
+
+## Symfony Compatibility
+
+This package now includes **Symfony-compatible controller methods** through the `AbstractController` class, making it easier to write Symfony-style code while maintaining backwards compatibility.
+
+See [SYMFONY_COMPATIBILITY.md](SYMFONY_COMPATIBILITY.md) for detailed documentation on:
+- Using Symfony-style methods (`json()`, `render()`, `redirectToRoute()`, etc.)
+- Migration guide from `Controller` to `AbstractController`
+- Security, forms, and flash message handling
+- Differences from Symfony and future migration path
diff --git a/SYMFONY_COMPATIBILITY.md b/SYMFONY_COMPATIBILITY.md
new file mode 100644
index 0000000..3da1b71
--- /dev/null
+++ b/SYMFONY_COMPATIBILITY.md
@@ -0,0 +1,306 @@
+# Symfony Compatibility
+
+The `bytic/controllers` package now provides Symfony-compatible controller methods through the `AbstractController` class, making it easier to migrate to Symfony patterns while maintaining backwards compatibility with existing code.
+
+## AbstractController
+
+The `AbstractController` extends the base `Controller` class and adds Symfony-style methods that make controllers more similar to Symfony's AbstractController.
+
+### Usage
+
+Simply extend `AbstractController` instead of `Controller`:
+
+```php
+getProducts();
+
+ // Use Symfony-style render method
+ return $this->render('products/index', [
+ 'products' => $products
+ ]);
+ }
+
+ public function show($id)
+ {
+ $product = $this->findProduct($id);
+
+ if (!$product) {
+ // Use Symfony-style exception
+ throw $this->createNotFoundException('Product not found');
+ }
+
+ // Use Symfony-style JSON response
+ return $this->json($product);
+ }
+}
+```
+
+## Available Methods
+
+### Response Methods
+
+#### `json(mixed $data, int $status = 200, array $headers = [], array $context = []): JsonResponse`
+
+Returns a JSON response.
+
+```php
+return $this->json(['status' => 'success', 'data' => $data]);
+```
+
+#### `render(string $view, array $parameters = [], ?Response $response = null): Response`
+
+Renders a view and returns a Response.
+
+```php
+return $this->render('products/show', ['product' => $product]);
+```
+
+#### `renderView(string $view, array $parameters = []): string`
+
+Renders a view and returns the rendered content as a string.
+
+```php
+$html = $this->renderView('emails/welcome', ['user' => $user]);
+```
+
+### Redirect Methods
+
+#### `redirectResponse(string $url, int $status = 302): RedirectResponse`
+
+Returns a RedirectResponse to the given URL.
+
+```php
+return $this->redirectResponse('/success');
+```
+
+#### `redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse`
+
+Returns a RedirectResponse to the given route.
+
+```php
+return $this->redirectToRoute('product_show', ['id' => $product->getId()]);
+```
+
+#### `generateUrl(string $route, array $parameters = []): string`
+
+Generates a URL from route parameters.
+
+```php
+$url = $this->generateUrl('product_edit', ['id' => 123]);
+```
+
+### File & Streaming Methods
+
+#### `file(\SplFileInfo|string $file, ?string $fileName = null, string $disposition = 'attachment'): BinaryFileResponse`
+
+Returns a file download response.
+
+```php
+return $this->file('/path/to/file.pdf', 'invoice.pdf');
+```
+
+#### `stream(\Closure $callback, int $status = 200, array $headers = []): StreamedResponse`
+
+Returns a streamed response.
+
+```php
+return $this->stream(function() {
+ echo "Streaming data...";
+});
+```
+
+### Flash Messages
+
+#### `addFlash(string $type, mixed $message): void`
+
+Adds a flash message to the session.
+
+```php
+$this->addFlash('success', 'Product saved successfully!');
+return $this->redirectToRoute('product_index');
+```
+
+### Security Methods
+
+#### `isGranted(mixed $attribute, mixed $subject = null): bool`
+
+Checks if the current user has the given permission.
+
+```php
+if ($this->isGranted('ROLE_ADMIN')) {
+ // Show admin panel
+}
+```
+
+#### `denyAccessUnlessGranted(mixed $attribute, mixed $subject = null, string $message = 'Access Denied.'): void`
+
+Throws an exception unless the user has the given permission.
+
+```php
+$this->denyAccessUnlessGranted('ROLE_ADMIN', null, 'You need admin access');
+```
+
+#### `getUser(): mixed`
+
+Gets the current authenticated user.
+
+```php
+$user = $this->getUser();
+if ($user) {
+ // User is authenticated
+}
+```
+
+### Exception Methods
+
+#### `createNotFoundException(string $message = 'Not Found', ?\Throwable $previous = null): NotFoundHttpException`
+
+Creates a 404 Not Found exception.
+
+```php
+throw $this->createNotFoundException('Product not found');
+```
+
+#### `createAccessDeniedException(string $message = 'Access Denied.', ?\Throwable $previous = null): AccessDeniedException`
+
+Creates a 403 Access Denied exception.
+
+```php
+throw $this->createAccessDeniedException('You cannot access this resource');
+```
+
+### Form Methods
+
+#### `createForm(string $type, mixed $data = null, array $options = []): mixed`
+
+Creates a form instance.
+
+```php
+$form = $this->createForm(ProductType::class, $product);
+```
+
+#### `createFormBuilder(mixed $data = null, array $options = []): mixed`
+
+Creates a form builder instance.
+
+```php
+$form = $this->createFormBuilder($product)
+ ->add('name')
+ ->add('price')
+ ->getForm();
+```
+
+### Configuration
+
+#### `getParameter(string $name): array|bool|string|int|float|\UnitEnum|null`
+
+Gets a configuration parameter.
+
+```php
+$apiKey = $this->getParameter('api.key');
+```
+
+#### `isCsrfTokenValid(string $id, ?string $token): bool`
+
+Validates a CSRF token.
+
+```php
+if ($this->isCsrfTokenValid('delete_product', $request->get('token'))) {
+ // Token is valid
+}
+```
+
+## Backwards Compatibility
+
+The `AbstractController` maintains full backwards compatibility with the existing `Controller` class. All existing traits and methods continue to work as before:
+
+- `payload()` - Access response payload
+- `getView()` - Get view object
+- `getRequest()` - Get current request
+- `forward()` - Forward to another action
+- `call()` - Call another action
+- All lifecycle hooks (`before()`, `after()`, etc.)
+
+### Migration Path
+
+You can migrate controllers incrementally:
+
+1. **Start**: Extend `AbstractController` instead of `Controller`
+2. **Gradually adopt**: Use new Symfony-style methods in new code
+3. **Keep existing**: Old code continues to work
+4. **Refactor when ready**: Update old methods to Symfony style at your pace
+
+### Differences from Symfony
+
+While the methods are designed to be similar to Symfony's AbstractController, there are some differences:
+
+1. **Dependency Injection**: Uses service locator pattern (`app()`) instead of constructor injection
+2. **View Rendering**: Integrates with the existing View system instead of Twig
+3. **Response Handling**: Works with the existing ResponsePayload system
+4. **Router**: Falls back to basic URL generation if router service is not available
+
+## Example Migration
+
+### Before (using Controller)
+```php
+class ProductController extends Controller
+{
+ public function show()
+ {
+ $product = $this->findProduct();
+ $this->payload()->with(['product' => $product]);
+ $this->payload()->withDefaultFormat('json');
+ return $this->newResponse();
+ }
+}
+```
+
+### After (using AbstractController)
+```php
+class ProductController extends AbstractController
+{
+ public function show()
+ {
+ $product = $this->findProduct();
+ return $this->json(['product' => $product]);
+ }
+}
+```
+
+Or with views:
+
+```php
+class ProductController extends AbstractController
+{
+ public function show()
+ {
+ $product = $this->findProduct();
+ return $this->render('products/show', ['product' => $product]);
+ }
+}
+```
+
+## Future Compatibility
+
+This implementation is designed to make future migration to Symfony easier by:
+
+1. Using the same method names and signatures as Symfony
+2. Supporting the same exception types
+3. Following Symfony's conventions and patterns
+4. Maintaining compatibility with Symfony components (HttpFoundation, Security, etc.)
+
+When you're ready to fully migrate to Symfony, you'll be able to:
+
+1. Switch from `Nip\Controllers\AbstractController` to `Symfony\Bundle\FrameworkBundle\Controller\AbstractController`
+2. Update dependency injection from service locator to constructor injection
+3. Replace view rendering with Twig templates
+4. Most controller code will work with minimal changes
From 8c29573d05ade67b0bd7c7725a0300e6b805269b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 09:32:08 +0000
Subject: [PATCH 4/6] Address code review feedback - improve error messages and
documentation
Co-authored-by: gabrielsolomon <17990591+gabrielsolomon@users.noreply.github.com>
---
src/AbstractController.php | 13 +--
.../Controllers/SymfonyStyleController.php | 96 +++++++++++++++++++
2 files changed, 103 insertions(+), 6 deletions(-)
create mode 100644 tests/fixtures/Controllers/SymfonyStyleController.php
diff --git a/src/AbstractController.php b/src/AbstractController.php
index 9956000..4f535e6 100644
--- a/src/AbstractController.php
+++ b/src/AbstractController.php
@@ -171,7 +171,7 @@ protected function addFlash(string $type, mixed $message): void
protected function isGranted(mixed $attribute, mixed $subject = null): bool
{
if (!function_exists('app') || !app()->has('security.authorization_checker')) {
- throw new \LogicException('The SecurityBundle is not registered in your application. Try running "composer require symfony/security-bundle".');
+ throw new \LogicException('Security authorization checker is not available. Register a "security.authorization_checker" service in your application container.');
}
return app('security.authorization_checker')->isGranted($attribute, $subject);
@@ -196,6 +196,7 @@ protected function denyAccessUnlessGranted(
): void {
if (!$this->isGranted($attribute, $subject)) {
$exception = $this->createAccessDeniedException($message);
+ // setAttributes and setSubject methods available in Symfony Security 5.0+
if (method_exists($exception, 'setAttributes')) {
$exception->setAttributes([$attribute]);
}
@@ -210,14 +211,14 @@ protected function denyAccessUnlessGranted(
/**
* Get a user from the Security Token Storage.
*
- * @return mixed|null
+ * @return mixed|null Returns the user object or null if not authenticated
*
* @throws \LogicException If SecurityBundle is not available
*/
protected function getUser(): mixed
{
if (!function_exists('app') || !app()->has('security.token_storage')) {
- throw new \LogicException('The SecurityBundle is not registered in your application. Try running "composer require symfony/security-bundle".');
+ throw new \LogicException('Security token storage is not available. Register a "security.token_storage" service in your application container.');
}
$token = app('security.token_storage')->getToken();
@@ -297,7 +298,7 @@ protected function stream(
protected function isCsrfTokenValid(string $id, ?string $token): bool
{
if (!function_exists('app') || !app()->has('security.csrf.token_manager')) {
- throw new \LogicException('CSRF protection is not enabled in your application. Enable it with the "csrf_protection" key in "config/packages/framework.yaml".');
+ throw new \LogicException('CSRF protection is not enabled. Register a "security.csrf.token_manager" service in your application container.');
}
return app('security.csrf.token_manager')->isTokenValid(
@@ -319,7 +320,7 @@ protected function isCsrfTokenValid(string $id, ?string $token): bool
protected function createForm(string $type, mixed $data = null, array $options = []): mixed
{
if (!function_exists('app') || !app()->has('form.factory')) {
- throw new \LogicException('Forms are not enabled in your application. Try running "composer require symfony/form".');
+ throw new \LogicException('Form factory is not available. Register a "form.factory" service in your application container.');
}
return app('form.factory')->create($type, $data, $options);
@@ -338,7 +339,7 @@ protected function createForm(string $type, mixed $data = null, array $options =
protected function createFormBuilder(mixed $data = null, array $options = []): mixed
{
if (!function_exists('app') || !app()->has('form.factory')) {
- throw new \LogicException('Forms are not enabled in your application. Try running "composer require symfony/form".');
+ throw new \LogicException('Form factory is not available. Register a "form.factory" service in your application container.');
}
return app('form.factory')->createBuilder(
diff --git a/tests/fixtures/Controllers/SymfonyStyleController.php b/tests/fixtures/Controllers/SymfonyStyleController.php
new file mode 100644
index 0000000..d03cb95
--- /dev/null
+++ b/tests/fixtures/Controllers/SymfonyStyleController.php
@@ -0,0 +1,96 @@
+ 1, 'name' => 'Product 1', 'price' => 99.99],
+ ['id' => 2, 'name' => 'Product 2', 'price' => 149.99],
+ ];
+
+ return $this->json([
+ 'success' => true,
+ 'data' => $products,
+ ]);
+ }
+
+ /**
+ * Example: View rendering.
+ */
+ public function showProduct($id)
+ {
+ $product = $this->findProduct($id);
+
+ if (!$product) {
+ throw $this->createNotFoundException('Product not found');
+ }
+
+ return $this->render('products/show', [
+ 'product' => $product,
+ ]);
+ }
+
+ /**
+ * Example: Redirect with flash message.
+ */
+ public function createProduct()
+ {
+ // ... save product logic ...
+
+ $this->addFlash('success', 'Product created successfully!');
+
+ return $this->redirectToRoute('products');
+ }
+
+ /**
+ * Example: Security check.
+ */
+ public function deleteProduct($id)
+ {
+ $this->denyAccessUnlessGranted('ROLE_ADMIN', null, 'Only admins can delete products');
+
+ // ... delete logic ...
+
+ return $this->redirectResponse('/products');
+ }
+
+ /**
+ * Example: File download.
+ */
+ public function downloadInvoice($id)
+ {
+ // In production, resolve the actual file path from your storage
+ $invoicePath = $this->getInvoiceStoragePath() . "/invoice-{$id}.pdf";
+
+ return $this->file($invoicePath, "invoice-{$id}.pdf");
+ }
+
+ /**
+ * Dummy method for example - replace with actual storage path resolution.
+ */
+ private function getInvoiceStoragePath()
+ {
+ return '/var/www/storage/invoices';
+ }
+
+ /**
+ * Dummy method for example.
+ */
+ private function findProduct($id)
+ {
+ return ['id' => $id, 'name' => 'Sample Product'];
+ }
+}
From 136de2a05469daf8ebdc9ecb1576cbac87a9ec42 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 09:33:25 +0000
Subject: [PATCH 5/6] Add migration summary documentation
Co-authored-by: gabrielsolomon <17990591+gabrielsolomon@users.noreply.github.com>
---
MIGRATION_SUMMARY.md | 216 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 216 insertions(+)
create mode 100644 MIGRATION_SUMMARY.md
diff --git a/MIGRATION_SUMMARY.md b/MIGRATION_SUMMARY.md
new file mode 100644
index 0000000..fba3e46
--- /dev/null
+++ b/MIGRATION_SUMMARY.md
@@ -0,0 +1,216 @@
+# Migration Summary: Symfony Compatibility Update
+
+## Overview
+
+This update adds Symfony-compatible controller methods to the bytic/controllers package while maintaining full backwards compatibility with existing code.
+
+## What Was Added
+
+### 1. AbstractController Class (`src/AbstractController.php`)
+
+A new abstract controller class that extends the existing `Controller` class and provides Symfony-style methods:
+
+**Response Methods:**
+- `json()` - Create JSON responses
+- `render()` - Render views and return Response
+- `renderView()` - Render views and return string
+- `file()` - File download responses
+- `stream()` - Streamed responses
+
+**Redirect Methods:**
+- `redirectResponse()` - Redirect to URL
+- `redirectToRoute()` - Redirect to named route
+- `generateUrl()` - Generate URLs from routes
+
+**Security Methods:**
+- `isGranted()` - Check permissions
+- `denyAccessUnlessGranted()` - Enforce permissions
+- `getUser()` - Get authenticated user
+- `isCsrfTokenValid()` - Validate CSRF tokens
+
+**Form Methods:**
+- `createForm()` - Create form instances
+- `createFormBuilder()` - Create form builders
+
+**Utility Methods:**
+- `addFlash()` - Add flash messages
+- `getParameter()` - Get configuration parameters
+
+**Exception Methods (inherited from ErrorHandling trait):**
+- `createNotFoundException()` - Create 404 exceptions
+- `createAccessDeniedException()` - Create 403 exceptions
+
+### 2. Documentation
+
+- **SYMFONY_COMPATIBILITY.md** - Comprehensive guide with:
+ - Usage examples for all methods
+ - Migration path from Controller to AbstractController
+ - Comparison with Symfony's AbstractController
+ - Differences and compatibility notes
+
+- **README.md** - Updated with Symfony compatibility section
+
+### 3. Tests
+
+- **tests/src/AbstractControllerTest.php** - Unit tests for AbstractController methods
+- **tests/fixtures/Controllers/SymfonyStyleController.php** - Example controller demonstrating Symfony patterns
+
+## Backwards Compatibility
+
+✅ **100% Backwards Compatible**
+
+- All existing `Controller` functionality remains unchanged
+- Existing controllers work without modification
+- All traits and methods continue to work as before
+- Service locator pattern (`app()`) preserved
+- ResponsePayload system continues to work
+
+## Migration Path
+
+### Incremental Adoption
+
+1. **Phase 1**: Extend `AbstractController` instead of `Controller`
+ ```php
+ class MyController extends AbstractController { }
+ ```
+
+2. **Phase 2**: Use Symfony methods in new code
+ ```php
+ public function newAction()
+ {
+ return $this->json(['status' => 'success']);
+ }
+ ```
+
+3. **Phase 3**: Keep existing code working
+ ```php
+ public function oldAction()
+ {
+ $this->payload()->with(['data' => 'value']); // Still works!
+ return $this->newResponse();
+ }
+ ```
+
+4. **Phase 4**: Refactor when ready
+ ```php
+ public function refactoredAction()
+ {
+ return $this->render('view', ['data' => 'value']);
+ }
+ ```
+
+### No Breaking Changes
+
+- Controllers extending `Controller` continue to work
+- `payload()` system still available
+- View system integration preserved
+- All lifecycle hooks work
+- Request/response handling unchanged
+
+## Benefits
+
+### Immediate Benefits
+
+1. **Symfony-style code** - Write controllers like Symfony
+2. **Easier learning** - Familiar patterns for Symfony developers
+3. **Better documentation** - Standard Symfony method names
+4. **Incremental adoption** - Migrate at your own pace
+
+### Future Benefits
+
+1. **Easier Symfony migration** - When ready to fully migrate:
+ - Same method names and signatures
+ - Similar patterns and conventions
+ - Minimal code changes required
+
+2. **Modern patterns** - Aligns with current PHP/Symfony best practices
+
+3. **Community familiarity** - Standard patterns well-known to developers
+
+## Technical Details
+
+### Design Decisions
+
+1. **Service Locator Pattern**: Uses `app()` function for service access
+ - Maintains compatibility with existing architecture
+ - No constructor changes required
+ - Easy to use in existing codebase
+
+2. **Error Messages**: Generic, framework-agnostic messages
+ - Don't assume full Symfony framework
+ - Guide users to register required services
+ - Clear about what's needed
+
+3. **Method Naming**: Symfony-compatible where possible
+ - `redirectResponse()` instead of `redirect()` to avoid conflicts
+ - Same signatures as Symfony methods
+ - Compatible return types
+
+4. **Inheritance**: Extends existing Controller
+ - All existing functionality available
+ - No duplication of code
+ - Clean inheritance hierarchy
+
+### Code Quality
+
+- ✅ No syntax errors
+- ✅ PHP 8.0+ compatible
+- ✅ Proper type hints
+- ✅ DocBlock documentation
+- ✅ Code review passed
+- ✅ Security scan passed
+
+## Files Changed
+
+```
+README.md | 10 +
+SYMFONY_COMPATIBILITY.md | 306 +++++
+src/AbstractController.php | 351 ++++++
+tests/fixtures/Controllers/SymfonyStyleController.php | 96 ++
+tests/src/AbstractControllerTest.php | 125 ++
+-------------------------------------------------------------------
+5 files changed, 888 insertions(+)
+```
+
+## Next Steps
+
+### For Users
+
+1. **Read Documentation**: Review SYMFONY_COMPATIBILITY.md
+2. **Try It Out**: Extend AbstractController in a test controller
+3. **Experiment**: Use Symfony methods in new actions
+4. **Migrate**: Gradually adopt in existing code
+5. **Feedback**: Report any issues or suggestions
+
+### For Maintainers
+
+1. **Monitor Usage**: Track adoption and feedback
+2. **Improve Documentation**: Add more examples as needed
+3. **Add Features**: Consider additional Symfony-compatible methods
+4. **Performance**: Monitor any performance impacts
+5. **Tests**: Expand test coverage as usage grows
+
+## Compatibility Matrix
+
+| Component | Status | Notes |
+|-----------|--------|-------|
+| PHP 8.0+ | ✅ Full | Uses PHP 8.0 features (mixed types) |
+| Existing Controllers | ✅ Full | No breaking changes |
+| ResponsePayload | ✅ Full | Works with Symfony methods |
+| View System | ✅ Full | Integrated with render() |
+| Request Handling | ✅ Full | Compatible with existing |
+| Symfony HttpFoundation | ✅ Full | Uses Symfony components |
+| Symfony Security | ✅ Full | Uses Symfony security |
+| Symfony Forms | ✅ Partial | When form.factory available |
+
+## Summary
+
+This update successfully makes the bytic/controllers package more similar to Symfony's way while maintaining complete backwards compatibility. Users can now:
+
+- Write Symfony-style controllers
+- Use familiar Symfony method names
+- Migrate incrementally at their own pace
+- Prepare for potential future Symfony migration
+- Leverage modern PHP and Symfony patterns
+
+All while keeping existing code working without any changes!
From 7991e56ab02b313fb9e6a28c36883006a957e645 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 09:34:22 +0000
Subject: [PATCH 6/6] Add quick start guide with before/after examples
Co-authored-by: gabrielsolomon <17990591+gabrielsolomon@users.noreply.github.com>
---
QUICK_START.md | 250 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 250 insertions(+)
create mode 100644 QUICK_START.md
diff --git a/QUICK_START.md b/QUICK_START.md
new file mode 100644
index 0000000..f5c3a13
--- /dev/null
+++ b/QUICK_START.md
@@ -0,0 +1,250 @@
+# Quick Start: Using AbstractController
+
+## Before and After Examples
+
+### Example 1: JSON API Response
+
+**Before (using Controller):**
+```php
+getProducts();
+
+ $this->payload()->with(['products' => $products]);
+ $this->payload()->withDefaultFormat('json');
+
+ return $this->newResponse();
+ }
+}
+```
+
+**After (using AbstractController):**
+```php
+getProducts();
+
+ return $this->json(['products' => $products]);
+ }
+}
+```
+
+---
+
+### Example 2: View Rendering
+
+**Before (using Controller):**
+```php
+public function show($id)
+{
+ $product = $this->findProduct($id);
+
+ $this->getView()->set('product', $product);
+ $this->payload()->withDefaultFormat('view');
+
+ return $this->newResponse();
+}
+```
+
+**After (using AbstractController):**
+```php
+public function show($id)
+{
+ $product = $this->findProduct($id);
+
+ return $this->render('products/show', [
+ 'product' => $product
+ ]);
+}
+```
+
+---
+
+### Example 3: Redirect with Flash Message
+
+**Before (using Controller):**
+```php
+public function create()
+{
+ // ... save product ...
+
+ $this->flashRedirect(
+ 'Product created successfully!',
+ '/products',
+ 'success'
+ );
+}
+```
+
+**After (using AbstractController):**
+```php
+public function create()
+{
+ // ... save product ...
+
+ $this->addFlash('success', 'Product created successfully!');
+
+ return $this->redirectResponse('/products');
+}
+```
+
+---
+
+### Example 4: 404 Not Found
+
+**Before (using Controller):**
+```php
+public function show($id)
+{
+ $product = $this->findProduct($id);
+
+ if (!$product) {
+ $this->dispatchNotFoundResponse();
+ }
+
+ // ... render product ...
+}
+```
+
+**After (using AbstractController):**
+```php
+public function show($id)
+{
+ $product = $this->findProduct($id);
+
+ if (!$product) {
+ throw $this->createNotFoundException('Product not found');
+ }
+
+ return $this->render('products/show', ['product' => $product]);
+}
+```
+
+---
+
+### Example 5: Security Check
+
+**Before (using Controller):**
+```php
+public function delete($id)
+{
+ // Manual check needed
+ if (!$this->userIsAdmin()) {
+ $this->dispatchAccessDeniedResponse();
+ }
+
+ // ... delete product ...
+}
+```
+
+**After (using AbstractController):**
+```php
+public function delete($id)
+{
+ $this->denyAccessUnlessGranted('ROLE_ADMIN');
+
+ // ... delete product ...
+
+ return $this->redirectToRoute('products_index');
+}
+```
+
+---
+
+### Example 6: File Download
+
+**Before (using Controller):**
+```php
+public function downloadInvoice($id)
+{
+ $path = "/path/to/invoice-{$id}.pdf";
+
+ $response = $this->getResponseFactory()->file($path, "invoice.pdf");
+
+ return $response;
+}
+```
+
+**After (using AbstractController):**
+```php
+public function downloadInvoice($id)
+{
+ $path = "/path/to/invoice-{$id}.pdf";
+
+ return $this->file($path, "invoice.pdf");
+}
+```
+
+---
+
+## Quick Reference
+
+### Response Methods
+
+| Old Way | New Way |
+|---------|---------|
+| `$this->payload()->with($data);`
`$this->payload()->withDefaultFormat('json');`
`return $this->newResponse();` | `return $this->json($data);` |
+| `$this->getView()->set($key, $value);`
`return $this->newResponse();` | `return $this->render($view, [$key => $value]);` |
+| `$this->redirect($url);` | `return $this->redirectResponse($url);` |
+
+### Security Methods
+
+| Old Way | New Way |
+|---------|---------|
+| `$this->dispatchNotFoundResponse();` | `throw $this->createNotFoundException();` |
+| `$this->dispatchAccessDeniedResponse();` | `throw $this->createAccessDeniedException();` |
+| Custom checks | `$this->denyAccessUnlessGranted('ROLE_ADMIN');` |
+| Custom user retrieval | `$user = $this->getUser();` |
+
+### Flash Messages
+
+| Old Way | New Way |
+|---------|---------|
+| `app('flash.messages')->add($name, $type, $msg);` | `$this->addFlash($type, $msg);` |
+
+## Getting Started
+
+1. **Change your controller base class:**
+ ```php
+ // Before
+ class MyController extends Controller
+
+ // After
+ class MyController extends AbstractController
+ ```
+
+2. **Start using Symfony methods in new code**
+
+3. **Keep existing code working** - No need to change everything at once!
+
+4. **Refactor gradually** - Update methods as you work on them
+
+## Benefits
+
+✅ **Cleaner Code** - Less boilerplate, more expressive
+✅ **Symfony Familiar** - Standard patterns developers know
+✅ **Type Safety** - Better IDE support and type checking
+✅ **Future Proof** - Easier migration to full Symfony later
+✅ **No Breaking Changes** - Existing code continues to work
+
+## More Information
+
+- 📖 [Full Documentation](SYMFONY_COMPATIBILITY.md) - Complete guide with all methods
+- 📋 [Migration Summary](MIGRATION_SUMMARY.md) - Detailed migration information
+- 💻 [Example Controller](tests/fixtures/Controllers/SymfonyStyleController.php) - Working examples