├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── check ├── composer.json ├── package.json ├── phpunit.xml ├── resources └── js │ ├── AirwireWatcher.js │ ├── _types.d.ts │ ├── airwired.ts │ ├── index.ts │ └── plugins │ ├── alpine.ts │ └── vue.ts ├── routes └── airwire.php ├── src ├── Airwire.php ├── AirwireServiceProvider.php ├── Attributes │ ├── Encode.php │ └── Wired.php ├── Commands │ ├── ComponentCommand.php │ └── GenerateDefinitions.php ├── Component.php ├── Concerns │ ├── ManagesActions.php │ ├── ManagesLifecycle.php │ ├── ManagesState.php │ └── ManagesValidation.php ├── Http │ └── AirwireController.php ├── Testing │ ├── AirwireResponse.php │ ├── RequestBuilder.php │ └── ResponseMetadata.php └── TypehintConverter.php ├── tests ├── Airwire │ ├── ComponentTest.php │ ├── TypeScriptTest.php │ ├── TypehintsTest.php │ └── ValidationTest.php ├── Pest.php └── TestCase.php └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: stancl 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ master ] 7 | 8 | jobs: 9 | phpunit: 10 | name: Tests (Pest) L${{ matrix.laravel }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | laravel: [8, 9] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Install composer dependencies 19 | run: composer require "laravel/framework:^${{matrix.laravel}}.0" 20 | - name: Run tests 21 | run: vendor/bin/pest 22 | 23 | php-cs-fixer: 24 | name: Code style (php-cs-fixer) 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Install php-cs-fixer 29 | run: composer global require friendsofphp/php-cs-fixer 30 | - name: Run php-cs-fixer 31 | run: $HOME/.composer/vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php 32 | - name: Commit changes from php-cs-fixer 33 | uses: EndBug/add-and-commit@v5 34 | with: 35 | author_name: Samuel Štancl 36 | author_email: samuel.stancl@gmail.com 37 | message: Fix code style (php-cs-fixer) 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock 3 | node_modules 4 | package-lock.json 5 | .phpunit.result.cache 6 | .php-cs-fixer.cache 7 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | ['syntax' => 'short'], 8 | 'binary_operator_spaces' => [ 9 | 'default' => 'single_space', 10 | 'operators' => [ 11 | '=>' => null, 12 | '|' => 'no_space', 13 | ], 14 | ], 15 | 'blank_line_after_namespace' => true, 16 | 'blank_line_after_opening_tag' => true, 17 | 'no_superfluous_phpdoc_tags' => true, 18 | 'blank_line_before_statement' => [ 19 | 'statements' => ['return'], 20 | ], 21 | 'braces' => true, 22 | 'cast_spaces' => true, 23 | 'class_definition' => true, 24 | 'concat_space' => [ 25 | 'spacing' => 'one', 26 | ], 27 | 'declare_equal_normalize' => true, 28 | 'elseif' => true, 29 | 'encoding' => true, 30 | 'full_opening_tag' => true, 31 | 'declare_strict_types' => true, 32 | 'fully_qualified_strict_types' => true, // added by Shift 33 | 'function_declaration' => true, 34 | 'function_typehint_space' => true, 35 | 'heredoc_to_nowdoc' => true, 36 | 'include' => true, 37 | 'increment_style' => ['style' => 'post'], 38 | 'indentation_type' => true, 39 | 'linebreak_after_opening_tag' => true, 40 | 'line_ending' => true, 41 | 'lowercase_cast' => true, 42 | 'constant_case' => true, 43 | 'lowercase_keywords' => true, 44 | 'lowercase_static_reference' => true, // added from Symfony 45 | 'magic_method_casing' => true, // added from Symfony 46 | 'magic_constant_casing' => true, 47 | 'method_argument_space' => true, 48 | 'native_function_casing' => true, 49 | 'no_alias_functions' => true, 50 | 'no_extra_blank_lines' => [ 51 | 'tokens' => [ 52 | 'extra', 53 | 'throw', 54 | 'use', 55 | 'use_trait', 56 | ], 57 | ], 58 | 'no_blank_lines_after_class_opening' => true, 59 | 'no_blank_lines_after_phpdoc' => true, 60 | 'no_closing_tag' => true, 61 | 'no_empty_phpdoc' => true, 62 | 'no_empty_statement' => true, 63 | 'no_leading_import_slash' => true, 64 | 'no_leading_namespace_whitespace' => true, 65 | 'no_mixed_echo_print' => [ 66 | 'use' => 'echo', 67 | ], 68 | 'no_multiline_whitespace_around_double_arrow' => true, 69 | 'multiline_whitespace_before_semicolons' => [ 70 | 'strategy' => 'no_multi_line', 71 | ], 72 | 'no_short_bool_cast' => true, 73 | 'no_singleline_whitespace_before_semicolons' => true, 74 | 'no_spaces_after_function_name' => true, 75 | 'no_spaces_around_offset' => true, 76 | 'no_spaces_inside_parenthesis' => true, 77 | 'no_trailing_comma_in_list_call' => true, 78 | 'no_trailing_comma_in_singleline_array' => true, 79 | 'no_trailing_whitespace' => true, 80 | 'no_trailing_whitespace_in_comment' => true, 81 | 'no_unneeded_control_parentheses' => true, 82 | 'no_unreachable_default_argument_value' => true, 83 | 'no_useless_return' => true, 84 | 'no_whitespace_before_comma_in_array' => true, 85 | 'no_whitespace_in_blank_line' => true, 86 | 'normalize_index_brace' => true, 87 | 'not_operator_with_successor_space' => true, 88 | 'object_operator_without_whitespace' => true, 89 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 90 | 'phpdoc_indent' => true, 91 | 'general_phpdoc_tag_rename' => true, 92 | 'phpdoc_no_access' => true, 93 | 'phpdoc_no_package' => true, 94 | 'phpdoc_no_useless_inheritdoc' => true, 95 | 'phpdoc_scalar' => true, 96 | 'phpdoc_single_line_var_spacing' => true, 97 | 'phpdoc_summary' => true, 98 | 'phpdoc_to_comment' => false, 99 | 'phpdoc_trim' => true, 100 | 'phpdoc_types' => true, 101 | 'phpdoc_var_without_name' => true, 102 | 'psr_autoloading' => true, 103 | 'self_accessor' => true, 104 | 'short_scalar_cast' => true, 105 | 'simplified_null_return' => false, // disabled by Shift 106 | 'single_blank_line_at_eof' => true, 107 | 'single_blank_line_before_namespace' => true, 108 | 'single_class_element_per_statement' => true, 109 | 'single_import_per_statement' => false, 110 | 'single_line_after_imports' => true, 111 | 'no_unused_imports' => true, 112 | 'single_line_comment_style' => [ 113 | 'comment_types' => ['hash'], 114 | ], 115 | 'single_quote' => true, 116 | 'space_after_semicolon' => true, 117 | 'standardize_not_equals' => true, 118 | 'switch_case_semicolon_to_colon' => true, 119 | 'switch_case_space' => true, 120 | 'ternary_operator_spaces' => true, 121 | 'trailing_comma_in_multiline' => true, 122 | 'trim_array_spaces' => true, 123 | 'unary_operator_spaces' => true, 124 | 'whitespace_after_comma_in_array' => true, 125 | ]; 126 | 127 | $project_path = getcwd(); 128 | $finder = Finder::create() 129 | ->in([ 130 | $project_path . '/src', 131 | ]) 132 | ->name('*.php') 133 | ->notName('*.blade.php') 134 | ->ignoreDotFiles(true) 135 | ->ignoreVCS(true); 136 | 137 | return (new Config()) 138 | ->setFinder($finder) 139 | ->setRules($rules) 140 | ->setRiskyAllowed(true) 141 | ->setUsingCache(true); 142 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 ARCHTECH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > Note: **Development is currently paused**. It will be resumed after we launch [Lean Admin](https://lean-admin.dev) this year. 2 | 3 | # Airwire 4 | 5 | *A lightweight full-stack component layer that doesn't dictate your front-end framework* 6 | 7 | [Demo](https://github.com/archtechx/airwire-demo) 8 | 9 | ## Introduction 10 | 11 | Airwire is a thin layer between your Laravel code and your JavaScript. 12 | 13 | It lets you write Livewire-style OOP components like this: 14 | 15 | ```php 16 | class CreateUser extends Component 17 | { 18 | #[Wired] 19 | public string $name = ''; 20 | 21 | #[Wired] 22 | public string $email = ''; 23 | 24 | #[Wired] 25 | public string $password = ''; 26 | 27 | #[Wired] 28 | public string $password_confirmation = ''; 29 | 30 | public function rules() 31 | { 32 | return [ 33 | 'name' => ['required', 'min:5', 'max:25', 'unique:users'], 34 | 'email' => ['required', 'unique:users'], 35 | 'password' => ['required', 'min:8', 'confirmed'], 36 | ]; 37 | } 38 | 39 | #[Wired] 40 | public function submit(): User 41 | { 42 | $user = User::create($this->validated()); 43 | 44 | $this->meta('notification', __('users.created', ['id' => $user->id, 'name' => $user->name])); 45 | 46 | $this->reset(); 47 | 48 | return $user; 49 | } 50 | } 51 | ``` 52 | 53 | Then, it generates a TypeScript definition like this: 54 | 55 | ```ts 56 | interface CreateUser { 57 | name: string; 58 | email: string; 59 | password: string; 60 | password_confirmation: string; 61 | submit(): AirwirePromise; 62 | errors: { ... } 63 | 64 | // ... 65 | } 66 | ``` 67 | 68 | And Airwire will wire the two parts together. It's up to you what frontend framework you use (if any), Airwire will simply forward calls and sync state between the frontend and the backend. 69 | 70 | The most basic use of Airwire would look like this: 71 | 72 | ```ts 73 | let component = Airwire.component('create-user') 74 | 75 | console.log(component.name); // your IDE knows that this is a string 76 | 77 | component.name = 'foo'; 78 | 79 | component.errors; // { name: ['The name must be at least 10 characters.'] } 80 | 81 | // No point in making three requests here, so let's defer the changes 82 | component.deferred.name = 'foobar'; 83 | component.deferred.password = 'secret123'; 84 | component.deferred.password_confirmation = 'secret123'; 85 | 86 | // Watch all received responses 87 | component.watch(response => { 88 | if (response.metadata.notification) { 89 | alert(response.metadata.notification) 90 | } 91 | }) 92 | 93 | component.submit().then(user => { 94 | // TS knows the exact data structure of 'user' 95 | console.log(user.created_at); 96 | }) 97 | ``` 98 | 99 | ## Installation 100 | 101 | *Laravel 8 and PHP 8 are needed.* 102 | 103 | First install the package via composer: 104 | ``` 105 | composer require archtechx/airwire 106 | ``` 107 | 108 | Then go to your `webpack.mix.js` and register the watcher plugin. It will refresh the TypeScript definitions whenever you make a change to PHP code: 109 | 110 | ```js 111 | mix.webpackConfig({ 112 | plugins: [ 113 | new (require('./vendor/archtechx/airwire/resources/js/AirwireWatcher'))(require('chokidar')), 114 | ], 115 | }) 116 | ``` 117 | 118 | Next, generate the initial TS files: 119 | 120 | ``` 121 | php artisan airwire:generate 122 | ``` 123 | 124 | This will create `airwire.ts` and `airwired.d.ts`. Open your `app.ts` and import the former: 125 | 126 | ```ts 127 | import Airwire from './airwire' 128 | ``` 129 | 130 | If you have an `app.js` file instead of an `app.ts` file, change the file suffix and update your `webpack.mix.js` file: 131 | 132 | ```diff 133 | - mix.js('resources/js/app.js', 'public/js') 134 | + mix.ts('resources/js/app.ts', 'public/js') 135 | ``` 136 | 137 | If you're using TypeScript for the first time, you'll also need a `tsconfig.json` file in the the root of your project. You can use this one to get started: 138 | 139 | ```json 140 | { 141 | "compilerOptions": { 142 | "target": "es2017", 143 | "strict": true, 144 | "module": "es2015", 145 | "moduleResolution": "node", 146 | "experimentalDecorators": true, 147 | "sourceMap": true, 148 | "skipLibCheck": true 149 | }, 150 | "include": ["resources/js/**/*"] 151 | } 152 | ``` 153 | 154 | And that's all! Airwire is fully installed. 155 | 156 | ## PHP components 157 | 158 | ### Creating components 159 | 160 | To create a component run the `php artisan airwire:component` command. 161 | 162 | ``` 163 | php artisan airwire:component CreateUser 164 | ``` 165 | 166 | The command in the example will create a file in `app/Airwire/CreateUser.php`. 167 | 168 | Next, register it in your AppServiceProvider: 169 | 170 | ```php 171 | // boot() 172 | 173 | Airwire::component('create-user', CreateUser::class); 174 | ``` 175 | 176 | ### Wired properties and methods 177 | 178 | Component properties and methods will be shared with the frontend if they use the `#[Wired]` attribute (in contrast to Livewire, where `public` visibility is used for this). 179 | 180 | This means that your components can use properties (even public) just fine, and they won't be shared with the frontend until you explicitly add this attribute. 181 | 182 | ```php 183 | class CreateTeam extends Component 184 | { 185 | #[Wired] 186 | public string $name; // Shared 187 | 188 | public string $owner; // Not shared 189 | 190 | public function hydrate() 191 | { 192 | $this->owner = auth()->id(); 193 | } 194 | } 195 | ``` 196 | 197 | ### Lifecycle hooks 198 | 199 | As showed in the example above, Airwire has useful lifecycle hooks: 200 | 201 | ```php 202 | public function hydrate() 203 | { 204 | // Executed on each request, before any changes & calls are made 205 | } 206 | 207 | public function dehydrate() 208 | { 209 | // Executed when serving a response, before things like validation errors are serialized into array metadata 210 | } 211 | 212 | public function updating(string $property, mixed $value): bool 213 | { 214 | return false; // disallow this state change 215 | } 216 | 217 | public function updatingFoo(mixed $value): bool 218 | { 219 | return true; // allow this state change 220 | } 221 | 222 | public function updated(string $property, mixed $value): void 223 | { 224 | // execute side effects as a result of a state change 225 | } 226 | 227 | public function updatedFoo(mixed $value): void 228 | { 229 | // execute side effects as a result of a state change 230 | } 231 | 232 | public function changed(array $changes): void 233 | { 234 | // execute side effects $changes has a list of properties that were changed 235 | // i.e. passed validation and updating() hooks 236 | } 237 | ``` 238 | 239 | ### Validation 240 | 241 | Airwire components use **strict validation** by default. This means that no calls can be made if the provided data is invalid. 242 | 243 | To disable strict validation, set this property to false: 244 | ```php 245 | public bool $strictValidation = false; 246 | ``` 247 | 248 | Note that disabling strict validation means that you're fully responsible for validating all incoming input before making any potentially dangerous calls, such as database queries. 249 | 250 | ```php 251 | public array $rules = [ 252 | 'name' => ['required', 'string', 'max:100'], 253 | ]; 254 | 255 | // or ... 256 | public function rules() 257 | { 258 | return [ ... ]; 259 | } 260 | 261 | public function messages() 262 | { 263 | return [ ... ]; 264 | } 265 | 266 | public function attributes() 267 | { 268 | return [ ... ]; 269 | } 270 | ``` 271 | 272 | ### Custom types 273 | 274 | Airwire supports custom DTOs. Simply tell it how to decode (incoming requests) and encode (outgoing responses) the data: 275 | 276 | ```php 277 | Airwire::typeTransformer( 278 | type: MyDTO::class, 279 | decode: fn (array $data) => new MyDTO($data['foo'], $data['abc']), 280 | encode: fn (MyDTO $dto) => ['foo' => $dto->foo, 'abc' => $dto->abc], 281 | ); 282 | ``` 283 | 284 | This doesn't require changes to the DTO class, and it works with any classes that extend the class. 285 | 286 | ### Models 287 | 288 | A type transformer for models is included by default. It uses the `toArray()` method to generate a JSON-friendly representation of the model (which means that things like `$hidden` are respected). 289 | 290 | It supports converting received IDs to model instances: 291 | ```php 292 | // received: '3' 293 | public User $user; 294 | ``` 295 | 296 | Converting arrays/objects to unsaved instances: 297 | ```php 298 | // received: ['name' => 'Try Airwire on a new project', 'priority' => 'highest'] 299 | public function addTask(Task $task) 300 | { 301 | $task->save(); 302 | } 303 | ``` 304 | 305 | Converting properties/return values to arrays: 306 | ```php 307 | public User $user; 308 | // response: {"name": "John Doe", "email": "john@example.com", ... } 309 | 310 | public find(string $id): Response 311 | { 312 | return User::find($id); 313 | } 314 | // same response as the property 315 | ``` 316 | 317 | If you wish to have even more control over how the data should be encoded, on a property-by-property basis, you can add a `Decoded` attribute. This can be useful for returning the id of a model, even if a property holds its instance: 318 | ```php 319 | #[Wired] #[Encode(method: 'getKey')] 320 | public User $user; // returns '3' 321 | 322 | #[Wired] #[Encode(property: 'slug')] 323 | public Post $post; // returns 'introducing-airwire' 324 | 325 | #[Wired] #[Encode(function: 'generateHashid')] 326 | public Post $post; // returns the value of generateHashid($post) 327 | ``` 328 | 329 | ### Default values 330 | 331 | You can specify default values for properties that can't have them specified directly in the class: 332 | 333 | ```php 334 | #[Wired(default: [])] 335 | public Collection $results; 336 | ``` 337 | 338 | These values will be part of the generated JS files, which means that components will have correct initial state even if they're initialized purely on the frontend, before making a single request to the server. 339 | 340 | ### Readonly values 341 | 342 | Properties can also be readonly. This tells the frontend not to send them to the backend in request data. 343 | 344 | A good use case for readonly properties is data that's only written by the server, e.g. query results: 345 | 346 | ```php 347 | // Search/Filter component 348 | 349 | #[Wired(readonly: true, default: [])] 350 | public Collection $results; 351 | ``` 352 | 353 | ### Mounting components 354 | 355 | Components can have a `mount()` method, which returns initial state. This state is not accessible when the component is instantiated on the frontend (unlike default values of properties), so the component requests the data from the server. 356 | 357 | A good use case for `mount()` is `