Chapters
This course is archived!
While the concepts of this course are still largely applicable, it's built using an older version of Symfony (4) and React (16).
-
Course Code
Subscribe to download the code!Compatible PHP versions: ^7.2.0
Subscribe to download the code!Compatible PHP versions: ^7.2.0
-
This Video
Subscribe to download the video!
Subscribe to download the video!
-
Subtitles
Subscribe to download the subtitles!
Subscribe to download the subtitles!
-
Course Script
Subscribe to download the script!
Subscribe to download the script!
CSRF Protection Part 2
Scroll down to the script below, click on any sentence (including terminal blocks) to jump to that spot in the video!
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeThe only tricky thing is that we only want to require the Content-Type
header when the user is requesting an API endpoint. In our application, this means all the endpoints inside of RepLogController
. So, we could see if the URL starts with /reps
... but that could get ugly later if the API grows to a lot of other URLs.
If your app is entirely an API, that's easy! Or if all the URLs start with /api
, that's also easy to check.
But, in our app, let's use a different trick... which is gonna be kinda fun.
Above the controller class, add @Route()
with defaults={}
and a new flag that I'm inventing: _is_api
set to true
.
Show Lines
|
// ... lines 1 - 13 |
/** | |
Show Lines
|
// ... line 15 |
* @Route(defaults={"_is_api": true}) | |
*/ | |
class RepLogController extends BaseController | |
Show Lines
|
// ... lines 19 - 101 |
When you put an @Route
annotation above the controller class, it means its config will be applied to all of the routes below it. Now, inside of the subscriber, we can read this config. To see how, add dump($request->attributes->all())
then die.
Show Lines
|
// ... lines 1 - 9 |
public function onKernelRequest(GetResponseEvent $event) | |
{ | |
Show Lines
|
// ... lines 12 - 16 |
dump($request->attributes->all());die; | |
Show Lines
|
// ... lines 18 - 22 |
} | |
Show Lines
|
// ... lines 24 - 32 |
If you refresh the main page... no _is_api
here. But now go to /reps
. There it is! Any defaults
flags that we set are available in $request->attributes
.
Creating a Custom ApiRoute Annotation
The only problem is that this syntax is... oof... gross. Let's make it easier. In the Api
directory, create a new PHP class called ApiRoute
. Make this extend the normal Route
annotation class.
Yep, we're creating a brand new, customized route annotation. Add @Annotation
above the class.
Show Lines
|
// ... lines 1 - 6 |
/** | |
* @Annotation | |
*/ | |
class ApiRoute extends Route | |
{ | |
} |
If we did nothing else, we could at least go into our controller and use it: @ApiRoute()
.
Show Lines
|
// ... lines 1 - 14 |
/** | |
Show Lines
|
// ... line 16 |
* @ApiRoute(defaults={"_is_api": true}) | |
*/ | |
class RepLogController extends BaseController | |
Show Lines
|
// ... lines 20 - 102 |
Try it! Nothing changes. But now, in ApiRoute
, go to the Code -> Generate menu - or Command+N on a Mac - and override the getDefaults()
method. Return a merge of _is_api
set to true and parent::getDefaults()
.
Show Lines
|
// ... lines 1 - 11 |
public function getDefaults() | |
{ | |
return array_merge( | |
['_is_api' => true], | |
parent::getDefaults() | |
); | |
} | |
Show Lines
|
// ... lines 19 - 20 |
Nice, right? Back in the controller, remove the ugly defaults
stuff. Oh, and if you want to mark just one route as an API route, you can also use this new annotation above just one method.
Ok, go back and refresh! Got it!
Validating the Content-Type Header
Back in the subscriber, remove the dump. Then, if !$request->attributes->get('_is_api')
return. And now that we know were only operating on API requests, check the header: if $request->headers->get('Content-Type')
does not equal application/json
, we have a problem! Create a new 400 response: $response = new JsonResponse()
.
The data we send back doesn't matter - I'll add a message that says what went wrong. But, give this a 415 status code: this means "Unsupported Media Type". Finish this with $event->setResponse($response)
. This will completely stop the request: this response will be returned without even calling your controller.
Show Lines
|
// ... lines 1 - 10 |
public function onKernelRequest(GetResponseEvent $event) | |
{ | |
Show Lines
|
// ... lines 13 - 23 |
if (!$request->attributes->get('_is_api')) { | |
return; | |
} | |
if ($request->headers->get('Content-Type') != 'application/json') { | |
$response = new JsonResponse([ | |
'message' => 'Invalid Content-Type' | |
], 415); | |
$event->setResponse($response); | |
return; | |
} | |
} | |
Show Lines
|
// ... lines 38 - 46 |
Ok, let's try this! Find the rep_log_api.js
file and look down at createRepLog
. We are setting this Content-Type
header. So, this should work! Move over, go back to /lift
and refresh. I'll open my network tools. And.. yea! It totally works! But try to delete a rep log... failure! With a 415 status code.
Always Sending the Content-Type Header
This is because the DELETE endpoint does not set this header. And... hmm, it's kinda weird... because, for the DELETE endpoint, the body of the request is empty. There's some debate, but, because of this, some people would argue that this request should not need any Content-Type
header... because we're not really sending any JSON!
But, by requiring this header to always be set, we give our application a bit more security: it removes the possibility that's somebody could create a CSRF attack on that endpoint... or some future endpoint that we don't send any data to.
In other words, we are always going to set this header. Remove it from createRepLog
and go up to fetchJson()
so we can set this here. The only tricky thing is that it's possible that someone who calls this will pass a custom header, and we don't want to override that.
Add let headers =
and set this to the Content-Type
header. Then, if options && options.headers
- so, if the user passes a custom header, merge them together: headers =
, ...options.headers
then ...headers
. Then, delete that property and, below, pass headers
to headers
.
function fetchJson(url, options) { | |
let headers = {'Content-Type': 'application/json'}; | |
if (options && options.headers) { | |
headers = {...options.headers, ...headers}; | |
delete options.headers; | |
} | |
return fetch(url, Object.assign({ | |
Show Lines
|
// ... line 9 |
headers: headers, | |
}, options)) | |
Show Lines
|
// ... lines 12 - 17 |
} | |
Show Lines
|
// ... lines 19 - 53 |
Try it! Move over - looks like the page already refreshed. And... yes! We can delete again!
And we are protected from CSRF! That's because, first, we do not allow other domains to make AJAX calls to our site and, second, all of our API endpoints require a JSON body - which we explicitly required by looking for the Content-Type
header.
Oh my gosh.... we're done! That's it, that's everything! If you've made it all the way through, you rock! You have the tools to create the craziest frontend you can think of! And yes, there are more things in React that we could cover, like the React router or Redux, which adds a more complex architecture on top of React, but helps solve the problem of passing around so many props.
But, these are extras - go get some real-world success with React and report back! We'd love to know what you're building.
Alright people, seeya next time.
40 Comments
Hey Milan V.!
You're just *barely* too early - the video is posted now :). And, I'm *thrilled* you found the course useful - I had such a great time writing it with Franks help! And yes, Redux is absolutely something we're thinking about... it's not a certainty yet... we're waiting to see more people ask for it. However, this tutorial has been very well-received, so I think there will be good demand to keep going with Redux.
Cheers!
Awesome, great tutorials!
FYI with ES6 you don't have to add an object key when adding a variable to an object, so long as that variable's name matches the key. So in this case instead of
Object.assign({
credentials: 'same-origin',
headers: headers
}, options)
You can just put:
Object.assign({
credentials: 'same-origin',
headers
}, options)
Does the same thing!
Hey GDIBass!
Dude, you're 100% correct! This one still makes my head hurt... so I don't use it... yet. But, there have been many other ES6 things that I once did not like, and now love ;).
Glad you like the tutorial!
Cheers!
Hello everyone,headers = {...options.headers, headers};
doesnt work for me when I add a header in one of the exported api-methods
it results in following headers-Object:{"foo":"bar","Content-Type":"plain-text","headers":{"Content-Type":"application/json"}}
If I use headers = Object.assign({}, options.headers, headers);
everything works fine.
I used this method to test:
`
export function createRepLog(repLog) {
return fetchJson('/reps', {
method: 'POST',
body: JSON.stringify(repLog),
headers: {foo:'BAR', 'Content-Type': 'plain-text'}
});
}
`
The console-output for the fetch-request:<br />POST http://react-course/reps 415 (Unsupported Media Type)<br />
I am kinda confused ;-)
<br />let a = { foo: 'bar' };<br />let b = { foz: 'baz' };<br />console.log(JSON.stringify({...a, b}));<br />
results in:<br />{"foo":"bar","b":{"foz":"baz"}}<br />
thanks in advance and thank you for your awesome tutorial!
greetz Sam
Hey AndTheGodsMadeLove
You are totally right! Looks like Ryan managed it like if it were an array, the right approach is using Object.assing({}, obj1, obj2);
as you mentioned it.
Cheers!
Or... we can fix it like that: headers = {...options.headers, ...headers};
Hey Alexander P.
Sorry for the late response. Somehow I missed this comment
Indeed you are right, that code works but it only works because we have installed babel-plugin-transform-object-rest-spread
library
Cheers!
Hey there @franck
I find this course to be very well done and very useful even a few years after it was written.
I hope you get paid a lot for your expertise and pedagogy :)
I would love to see a course with some advanced features of react (routing, redux, local storage, session storage ...).
Hey Bruno D.
Thank you for your kind words, it means a lot. We have a course about React but it's not that advance as you may want, however, your feedback is very important to us and it help us to decide what course to develop next. Peace, cheers!
Just bought this course - thanks a lot guys for putting it together, a great way to support Symfony.
+1 from me for an advanced course as Bruno mentioned with routing, redux, local storage, session storage, needless to say, will buy it too! :-)
First of all, Ryan and team, congrats for the course.
# Congrats
I bought it a few days ago and it went beyond my expectations. It was the first time I bought in SymfonyCasts and from now on I will totally recommend to my friends and also will come back for more courses as the first option when I need newer stuff!
Said this, I feel something missing in the course. And if there's not a full chapter in it, probably at least "one pointer on how and where to investigate my myself" would be great:
# Bidi communication to update the state in real-time?
Modern applications not only react to user input at clicks, keystrokes and mouse movements, but can also react to server changes. This is typically seen in apps like Trello or Google Docs. I may have trello open in 2 windows and then update something in one of the windows and the other automatically gets updated.
Another use case could be a react frontend that displays some data that comes from the external world (weather, stock market, truck-monitoring, whatever).
I know that React is good at managing those websockets (I don't really need old-browser compat, I'm happy to link to any modern bidirectional channel using the simplest standard built-in technologies).
Having said this my (educated?) guess is that most probably we need to bind that "state of the component" to some data received on the websocket.
But, honestly... If I think how can I do it, I don't know even where to start.
# Concrete example
Say for the sake of the example, we want improve the lifter application so if I open it in 2 separate broswers (one window in Firefox and one window in Chrome), when I add a "Fat Cat x 50 reps" in Firefox, it automatically gets re-loaded in Chrome.
Doing it by polling is easy: Just fetch()
on GET
every 5 seconds and done. But this is not what I am referrring to.
Maybe this is a thing that is absolutely 100% aside from react and I just need to manage the state inside the websocket callbacks but even if this is the case, I don't know where to start off.
Expecting a video making two replog lifter windows synchronize is expecting too much. But a "pointer" on where to start and how to update React components "from the server" would be truly appreciated!
Said this; the course still is 10 out of 10!
Hey xmontero!
Thanks for the kind note and spreading the word - that seriously means a lot ❤️
Said this, I feel something missing in the course. And if there's not a full chapter in it, probably at least "one pointer on how and where to investigate my myself" would be great:
Ah yes, real-time communication! I can give you a pointer on this :). As you suspect, what you basically want to do is "push" some updated state from the server to the client. Like you said, it's basically like you want to do polling to ask for the "latest state" for some component, but without the polling.
The way we typically do this in Symfony is with Mercure: it's a modern solution that serves the same purpose as Websockets. Basically, you can trigger an "Update" on the server to one or more topics. The "Update" can contain any data, but in your case, it would contain JSON for some new state. Then, in JavaScript, you subscribe to the topic. Then, anytime an "Update" is sent to that topic, a callback is executed in JavaScript.
Expecting a video making two replog lifter windows synchronize is expecting too much. But a "pointer" on where to start and how to update React components "from the server" would be truly appreciated!
So, for this, let's say that we decide to subscribe to a topic called user_rep_list_##
where the ##
is the current user's id. You can make topics "private" so that not anybody can listen to them, but I'll keep that outside of my explanation. Anyways, you listen to this in JS, and have a callback that will take the data from this "update" (e.g. a JSON list of the current rep logs) and set it onto the state of a component. Or, maybe you get fancier and send JSON with something like an "add" key in it, then you look for that and "add" that new rep log - e.g.:
{
"operations": [
{
"action": "add",
"data": {... the rep log}
}
]
}
Hopefully you get the idea. Then, in PHP, on the Ajax endpoint that adds a new RepLog, you dispatch this Update
to mercure with whatever data you want.
We do not have a tutorial specifically about Mercure, but we touch on it a few times - like https://symfonycasts.com/screencast/turbo/mercure (and the next few chapters). The big difference there is that we're sending HTML in the Updates and using some external JS to parse that HTML and use it. You would send JSON and then write your own code to subscribe to that topic and parse the JSON.
I hope this helps - it's far from a full description, but it's at least a pointer ;).
Cheers!
That is even better than a "pointer" ❤️
Pointer would be "mentioning mercure". Pointer squared would be your full text. Pointer cubed is what you did: giving even the links to specific videos.
Will definitively try it!
Thanks a ton.
Haha, yay! Well, I hope it works out - I've used Mercure now several times, but still never TOO heavily (though it is super fun). Let me know how it goes.
Cheers!
Great tutorial! I learnt a lot!
Thanks a lot, We are very happy when our tutorial gives good knowledge!
Cheers!
I have a question related to cache. At the point in the tutorial where we add ApiRoute and then dump all attributes, I decide to go back and set _is_api => false just to see the result. But after refreshing the page, the dump still showed _is_api => true. I then did a clear:cache, but the attribute still had the old value. I had to actually delete the entire var/cache folder and refresh to see the 'false' value.
Do I have something set wrong in PHPStorm?
I've had this same problem in other Symfony apps where code changes I make do not take effect until i hard-delete cache. Very frustrating :/
Btw, I'm using the Symfony server, and the Symfony plugin is activated for this project.
Hey John christensen!
Hmm. That *is* interesting. The class behind the cached annotations is this class - specifically this function - https://github.com/doctrine...
See that $this->debug flag? Symfony passes true to this in debug mode, and false in prod mode. When debug is true, that class actually checks to see if the class that the annotation lives on has been modified since the cache - https://github.com/doctrine...
That's a long way of saying... I have no idea what's going on in your case! When you modify the annotation, that modifies the file... which should cause that isCacheFresh() method to return false. If you're able to repeat this issue (and if you care enough), you could add some temporary debug code to that class to see what's going on. Symfony goes a long way to make sure that these cache problems don't happen... so something *is* indeed odd in your situation!
Let me know if you find anything out :).
Cheers!
Hey guys,
Thanks for the awesome resource.
When I implement @ApiRoute, PhpStorm gets angry. $this->generateUrl('routename'
says missing route. Do you get the same result?
Hey Skylar
Probably PhpStorm Symfony plugin is not smart enough to read those annotations as a Symfony Route. Try re-indexing your project after that change if it doesn't work you may have to tweak some config in order to make it to work
Cheers!
I would looooove you guys if you did a course on single page apps using symfony and react. I am struggling with the idea of hardcoding routes when using react router, and I'm not sure how to let react take care of the front end while still leaving my api routes untouched.
Would be awesome if you could make a screencast about that stuff because it's super interesting and fun, and also, this screencast made me fall in love with react :D
Hey Tobias I.@
This is definitely on our radar - especially as we get more into API's etc. What I *can* tell you is that, about the hardcoding, i wouldn't worry about it. We *do* hardcode routes increasingly, because we're thinking of our API as its own "standalone" thing and our frontend as its own "standalone" thing. URLs then become a sort of "contract", where your API "promises" not to change them and then your frontend can hardcode them (this is exactly how the world works when the API and user of an API are not the same "app"). This just forces you to "think" before changing any URLs (similar to how you would "think: before renaming any methods in your code): if you update a URL in your API, you just need to find and update it on the frontend. You *can* also use FOSJsRoutingBundle, if this really bothers you :).
Sorry I don't have anything more ready to tell you than that right now! But this is a general direction that we're moving, of course, with our tutorials.
Cheers!
Hi Guys :) I've been trying to follow along for this chapter but I'm a bit stuck :(
It's the ApiRoute.php class. I've created it and I'm trying to use it now as an annotation above the route in my controller like so:
`
/**
- @ApiRoute()
- @Route("/stories/{slug}", name="stories_slug_set", methods={"POST"})
*/
`
But symfony seems to ignore it. When I try to dump($request->attributes->all())
, the _is_api key is not there. When I use @Route(defaults={"_is_api": true})
however, it <b>is</b> there!
Weird thing is, I tried to use the exercise files as a reference and check if I had missed something somewhere, but inside the finish folder the ApiRoute.php class file seems to be missing entirely? Hopefully you can help me out :)
Hey Bart
Which URL are you accessing? Remeber that it only works on RepLogsController routes. Oh, and can you double check that your ApiRoute extends from the right Symfony Route class?
Cheers!
Thanks for your reply Diego Aguiar :) I'm positive I'm extending the correct class, it's just that when I use the @ApiRoute()
annotation it doesn't work above a single method, it only works above the entire class. Ryan points out in the video it should work with just a single method?
Hmm, it only works when you use it on the entire class? That's odd. I may have to see your code, if you can upload it to Github or somewhere else
The code is visible at https://bitbucket.org/bschutte/apiroute/src/master/ Hopefully this allows you to spot the problem :) Alternatively you could check out the repo and have a look at http://127.0.0.1:8000/ which is a test page on which you can reproduce the problem
Wow, you are right, this is not working on methods. This might be a Symfony's bug. I'll ask Ryan about this
Thanks for sharing your case :)
Hey MolloKhan!
Ah, this was a tricky one! But.. it IS working ;). It comes down to one small detail. You currently have:
/**
* @ApiRoute()
* @Route("/stories/{slug}", name="stories_slug_set", methods={"POST"})
*/
public function setStory()
The result is that this created TWO routes - you can see them if you run php bin/console debug:router
:
-------------------- -------- -------- ------ --------------------------
Name Method Scheme Host Path
-------------------- -------- -------- ------ --------------------------
app_story_setstory ANY ANY ANY /
stories_slug_set GET ANY ANY /stories/{slug}
The FIRST one comes from the ApiRoute, and it DOES have the _is_api default. The second is just a normal route. Basically, you should use ApiRoute instead of Route, not along with it. Both Route and ApiRoute create routes. In your case, you've created two routes - one that is an "api route" and one that is a normal route. Try this instead:
/**
* @ApiRoute("/stories/{slug}", name="stories_slug_set", methods={"POST"})
*/
public function setStory()
Let me know if that works!
Cheers!
Hello. Thank you for the great tutorial. I already had an API with ApiKeys:
https://symfony.com/doc/cur...
everything works great. But I do not want to build a Single Page Application,
how can React remember the ApiKey between two page views? I think it's not
so good to hand it over via Twig ;) (I am a beginner with React...) Thank you
Hey Askan S.!
Great question :). Your first option is ... don't use API keys! Just use session-based storage and cookies like we do in this tutorial. It's simple & secure (as long as you have your user use HTTPS, which you always should). But yes, I see you are already using API keys ;).
The issue with API tokens is where to store them? And, honestly, there's a lot of conflicting info about this. For example: https://dev.to/rdegges/plea... - according to this resource, using local storage is not an option (though it would be really simple!). Other places - https://auth0.com/docs/secu... (which is a great site I trust) - absolutely show local storage being used - they even show it in their quick starter docs: https://auth0.com/docs/quic...
So... there is clearly some security issues with using local storage... though *how* big of a deal, is debated :/. You're right that putting the API key as a global JavaScript variable is not a good idea. Well, it's actually the same as "local storage" - other JavaScript running on your page could access it.
So, use local storage... or if you really care about security, use sessions & cookies :). That's probably not the exact answer you wanted, but that's the state of things :).
Cheers!
Dear weaverryan, thank you very much for your detailed answer.
I thought lot about it and came to similar conclusions,
and have rebuilt the app sessionbased like in your tutorial.
But there is a disadvantage. How can I share the Api with
other programs, that do not use the frontend? I think
a new Route ("apiextern") and in additional authorisation
etc. is the solution ;) Best Regards Askan
Hey Askan S.!
Ah! So if you want external programs to use your API, that's different. That IS a more appropriate use-case for having API tokens. What I'd recommend is *two* authenticators: one for the normal, session-based authentication and another that allows token-based authentication. Then, you can use the session-based login for your JavaScript app and allow the external applications to send an API token.
If you have some more questions about this, I'd be happy to answer :).
Cheers!
"Houston: no signs of life"
Start the conversation!
What PHP libraries does this tutorial use?
// composer.json
{
"require": {
"php": "^7.2.0",
"ext-iconv": "*",
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/annotations": "^1.0", // v1.8.0
"doctrine/doctrine-bundle": "^1.6", // 1.9.1
"doctrine/doctrine-cache-bundle": "^1.2", // 1.3.3
"doctrine/doctrine-fixtures-bundle": "~3.0", // 3.0.2
"doctrine/doctrine-migrations-bundle": "^1.2", // v1.3.1
"doctrine/orm": "^2.5", // v2.7.2
"friendsofsymfony/jsrouting-bundle": "^2.2", // 2.2.0
"friendsofsymfony/user-bundle": "dev-master#4125505ba6eba82ddf944378a3d636081c06da0c", // dev-master
"phpdocumentor/reflection-docblock": "^3.0|^4.0", // 4.3.0
"sensio/framework-extra-bundle": "^5.1", // v5.2.0
"symfony/asset": "^4.0", // v4.1.4
"symfony/cache": "^3.3|^4.0", // v4.1.4
"symfony/console": "^4.0", // v4.1.4
"symfony/flex": "^1.0", // v1.21.6
"symfony/form": "^4.0", // v4.1.4
"symfony/framework-bundle": "^4.0", // v4.1.4
"symfony/lts": "^4@dev", // dev-master
"symfony/monolog-bundle": "^3.1", // v3.3.0
"symfony/polyfill-apcu": "^1.0", // v1.9.0
"symfony/property-access": "^3.3|^4.0", // v4.1.4
"symfony/property-info": "^3.3|^4.0", // v4.1.4
"symfony/serializer": "^3.3|^4.0", // v4.1.4
"symfony/swiftmailer-bundle": "^3.1", // v3.2.3
"symfony/twig-bundle": "^4.0", // v4.1.4
"symfony/validator": "^4.0", // v4.1.4
"symfony/yaml": "^4.0", // v4.1.4
"twig/twig": "2.10.*" // v2.10.0
},
"require-dev": {
"easycorp/easy-log-handler": "^1.0.7", // v1.0.7
"symfony/debug-bundle": "^3.3|^4.0", // v4.1.4
"symfony/dotenv": "^4.0", // v4.1.4
"symfony/maker-bundle": "^1.5", // v1.5.0
"symfony/phpunit-bridge": "^4.0", // v4.1.4
"symfony/stopwatch": "^3.3|^4.0", // v4.1.4
"symfony/var-dumper": "^3.3|^4.0", // v4.1.4
"symfony/web-profiler-bundle": "^3.3|^4.0", // v4.1.4
"symfony/web-server-bundle": "^4.0" // v4.1.4
}
}
What JavaScript libraries does this tutorial use?
// package.json
{
"dependencies": {
"@babel/plugin-proposal-object-rest-spread": "^7.12.1" // 7.12.1
},
"devDependencies": {
"@babel/preset-react": "^7.0.0", // 7.12.5
"@symfony/webpack-encore": "^0.26.0", // 0.26.0
"babel-plugin-transform-object-rest-spread": "^6.26.0", // 6.26.0
"babel-plugin-transform-react-remove-prop-types": "^0.4.13", // 0.4.13
"bootstrap": "3", // 3.3.7
"copy-webpack-plugin": "^4.4.1", // 4.5.1
"core-js": "2", // 1.2.7
"eslint": "^4.19.1", // 4.19.1
"eslint-plugin-react": "^7.8.2", // 7.8.2
"font-awesome": "4", // 4.7.0
"jquery": "^3.3.1", // 3.3.1
"promise-polyfill": "^8.0.0", // 8.0.0
"prop-types": "^15.6.1", // 15.6.1
"react": "^16.3.2", // 16.4.0
"react-dom": "^16.3.2", // 16.4.0
"sass": "^1.29.0", // 1.29.0
"sass-loader": "^7.0.0", // 7.3.1
"sweetalert2": "^7.11.0", // 7.22.0
"uuid": "^3.2.1", // 3.4.0
"webpack-notifier": "^1.5.1", // 1.6.0
"whatwg-fetch": "^2.0.4" // 2.0.4
}
}
Cool guys!
I am a little bit too soon here, but that is because you made such a great course which I have been returning to every day! What a ride! Are you planning on adding Redux course too? You could call it something like: "JavaScript for PHP Geeks: Redux for the legends (with symfony)". :imagine_sun_glasses_emoji_here: